From be8745c70a18dd664528cf17f0b40ade7fce6ebb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Feb 2024 20:01:21 -0800 Subject: [PATCH 001/428] terminal: bunch of junk for paged terminal --- src/terminal/main.zig | 4 ++ src/terminal/new/page.zig | 95 ++++++++++++++++++++++++++++++++++++++ src/terminal/new/size.zig | 60 ++++++++++++++++++++++++ src/terminal/new/style.zig | 62 +++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 src/terminal/new/page.zig create mode 100644 src/terminal/new/size.zig create mode 100644 src/terminal/new/style.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index a4224e63a1..03712b7fd3 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -50,4 +50,8 @@ pub usingnamespace if (builtin.target.isWasm()) struct { test { @import("std").testing.refAllDecls(@This()); + + _ = @import("new/page.zig"); + _ = @import("new/size.zig"); + _ = @import("new/style.zig"); } diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig new file mode 100644 index 0000000000..51851ec70a --- /dev/null +++ b/src/terminal/new/page.zig @@ -0,0 +1,95 @@ +const std = @import("std"); +const assert = std.debug.assert; +const color = @import("../color.zig"); +const sgr = @import("../sgr.zig"); +const size = @import("size.zig"); +const Offset = size.Offset; + +/// A page represents a specific section of terminal screen. The primary +/// idea of a page is that it is a fully self-contained unit that can be +/// serialized, copied, etc. as a convenient way to represent a section +/// of the screen. +/// +/// This property is useful for renderers which want to copy just the pages +/// for the visible portion of the screen, or for infinite scrollback where +/// we may want to serialize and store pages that are sufficiently far +/// away from the current viewport. +/// +/// Pages are always backed by a single contiguous block of memory that is +/// aligned on a page boundary. This makes it easy and fast to copy pages +/// around. Within the contiguous block of memory, the contents of a page are +/// thoughtfully laid out to optimize primarily for terminal IO (VT streams) +/// and to minimize memory usage. +pub const Page = struct { + /// The backing memory for the page. A page is always made up of a + /// a single contiguous block of memory that is aligned on a page + /// boundary and is a multiple of the system page size. + /// + /// The backing memory is always zero initialized, so the zero value + /// of all data within the page must always be valid. + memory: []align(std.mem.page_size) u8, + + /// The array of rows in the page. The rows are always in row order + /// (i.e. index 0 is the top row, index 1 is the row below that, etc.) + rows: Offset(Row), + + /// The array of cells in the page. The cells are NOT in row order, + /// but they are in column order. To determine the mapping of cells + /// to row, you must use the `rows` field. From the pointer to the + /// first column, all cells in that row are laid out in column order. + cells: Offset(Cell), +}; + +pub const Row = packed struct { + /// The cells in the row offset from the page. + cells: Offset(Cell), +}; + +/// A cell represents a single terminal grid cell. +/// +/// The zero value of this struct must be a valid cell representing empty, +/// since we zero initialize the backing memory for a page. +pub const Cell = packed struct(u32) { + codepoint: u21 = 0, +}; + +/// The style attributes for a cell. +pub const Style = struct { + /// Various colors, all self-explanatory. + fg_color: Color = .none, + bg_color: Color = .none, + underline_color: Color = .none, + + /// On/off attributes that don't require much bit width so we use + /// a packed struct to make this take up significantly less space. + flags: 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, + } = .{}, + + /// The color for an SGR attribute. A color can come from multiple + /// sources so we use this to track the source plus color value so that + /// we can properly react to things like palette changes. + pub const Color = union(enum) { + none: void, + palette: u8, + rgb: color.RGB, + }; + + test { + // The size of the struct so we can be aware of changes. + const testing = std.testing; + try testing.expectEqual(@as(usize, 14), @sizeOf(Style)); + } +}; + +test { + _ = Page; + _ = Style; +} diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig new file mode 100644 index 0000000000..4443a92917 --- /dev/null +++ b/src/terminal/new/size.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const assert = std.debug.assert; + +/// The maximum size of a page in bytes. We use a u16 here because any +/// smaller bit size by Zig is upgraded anyways to a u16 on mainstream +/// CPU architectures, and because 65KB is a reasonable page size. To +/// support better configurability, we derive everything from this. +pub const max_page_size = 65_536; + +/// The int type that can contain the maximum memory offset in bytes, +/// derived from the maximum terminal page size. +pub const OffsetInt = std.math.IntFittingRange(0, max_page_size - 1); + +/// The int type that can contain the maximum number of cells in a page. +pub const CellCountInt = u16; // TODO: derive +// +/// The offset from the base address of the page to the start of some data. +/// This is typed for ease of use. +/// +/// This is a packed struct so we can attach methods to an int. +pub fn Offset(comptime T: type) type { + return packed struct(OffsetInt) { + const Self = @This(); + + offset: OffsetInt = 0, + + /// Returns a pointer to the start of the data, properly typed. + pub fn ptr(self: Self, base: anytype) [*]T { + // The offset must be properly aligned for the type since + // our return type is naturally aligned. We COULD modify this + // to return arbitrary alignment, but its not something we need. + assert(@mod(self.offset, @alignOf(T)) == 0); + return @ptrFromInt(@intFromPtr(base) + self.offset); + } + }; +} + +test "Offset" { + // This test is here so that if Offset changes, we can be very aware + // of this effect and think about the implications of it. + const testing = std.testing; + try testing.expect(OffsetInt == u16); +} + +test "Offset ptr u8" { + const testing = std.testing; + const offset: Offset(u8) = .{ .offset = 42 }; + const base_int: usize = @intFromPtr(&offset); + const actual = offset.ptr(&offset); + try testing.expectEqual(@as(usize, base_int + 42), @intFromPtr(actual)); +} + +test "Offset ptr structural" { + const Struct = struct { x: u32, y: u32 }; + const testing = std.testing; + const offset: Offset(Struct) = .{ .offset = @alignOf(Struct) * 4 }; + const base_int: usize = @intFromPtr(&offset); + const actual = offset.ptr(&offset); + try testing.expectEqual(@as(usize, base_int + offset.offset), @intFromPtr(actual)); +} diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig new file mode 100644 index 0000000000..6fdb8c3e73 --- /dev/null +++ b/src/terminal/new/style.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const color = @import("../color.zig"); +const sgr = @import("../sgr.zig"); +const size = @import("size.zig"); + +/// The unique identifier for a style. This is at most the number of cells +/// that can fit into a terminal page. +pub const Id = size.CellCountInt; + +/// The style attributes for a cell. +pub const Style = struct { + /// Various colors, all self-explanatory. + fg_color: Color = .none, + bg_color: Color = .none, + underline_color: Color = .none, + + /// On/off attributes that don't require much bit width so we use + /// a packed struct to make this take up significantly less space. + flags: 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, + } = .{}, + + /// The color for an SGR attribute. A color can come from multiple + /// sources so we use this to track the source plus color value so that + /// we can properly react to things like palette changes. + pub const Color = union(enum) { + none: void, + palette: u8, + rgb: color.RGB, + }; + + test { + // The size of the struct so we can be aware of changes. + const testing = std.testing; + try testing.expectEqual(@as(usize, 14), @sizeOf(Style)); + } +}; + +/// Maps a style definition to metadata about that style. +pub const MetadataMap = std.AutoHashMapUnmanaged(Style, Metadata); + +/// Maps the unique style ID to the concrete style definition. +pub const IdMap = std.AutoHashMapUnmanaged(size.CellCountInt, Style); + +/// Metadata about a style. This is used to track the reference count +/// and the unique identifier for a style. The unique identifier is used +/// to track the style in the full style map. +pub const Metadata = struct { + ref: size.CellCountInt = 0, + id: size.CellCountInt = 0, +}; + +test { + _ = Style; +} From 18810f89f71da5ea50771951d951f0c6c59063c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 14:34:54 -0800 Subject: [PATCH 002/428] terminal: copy stdlib hash_map --- src/terminal/main.zig | 1 + src/terminal/new/hash_map.zig | 1544 +++++++++++++++++++++++++++++++++ 2 files changed, 1545 insertions(+) create mode 100644 src/terminal/new/hash_map.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 03712b7fd3..0324556aa0 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -51,6 +51,7 @@ pub usingnamespace if (builtin.target.isWasm()) struct { test { @import("std").testing.refAllDecls(@This()); + _ = @import("new/hash_map.zig"); _ = @import("new/page.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig new file mode 100644 index 0000000000..119e3e28aa --- /dev/null +++ b/src/terminal/new/hash_map.zig @@ -0,0 +1,1544 @@ +//! This file contains a fork of the Zig stdlib HashMap implementation tuned +//! for use with our terminal page representation. +//! +//! The main goal we need to achieve that wasn't possible with the stdlib +//! HashMap is to utilize offsets rather than full pointers so that we can +//! copy around the entire backing memory and keep the hash map working. +//! +//! Additionally, for serialization/deserialization purposes, we need to be +//! able to create a HashMap instance and manually set the offsets up. The +//! stdlib HashMap does not export Metadata so this isn't possible. +//! +//! Also, I want to be able to understand possible capacity for a given K,V +//! type and fixed memory amount. The stdlib HashMap doesn't publish its +//! internal allocation size calculation. +//! +//! Finally, I removed many of the APIs that we'll never require for our +//! usage just so that this file is smaller, easier to understand, and has +//! less opportunity for bugs. +//! +//! Besides these shortcomings, the stdlib HashMap has some great qualities +//! that we want to keep, namely the fact that it is backed by a single large +//! allocation rather than pointers to separate allocations. This is important +//! because our terminal page representation is backed by a single large +//! allocation so we can give the HashMap a slice of memory to operate in. +//! +//! I haven't carefully benchmarked this implementation against other hash +//! map implementations. It's possible using some of the newer variants out +//! there would be better. However, I trust the built-in version is pretty good +//! and its more important to get the terminal page representation working +//! first then we can measure and improve this later if we find it to be a +//! bottleneck. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const autoHash = std.hash.autoHash; +const math = std.math; +const mem = std.mem; +const Allocator = mem.Allocator; +const Wyhash = std.hash.Wyhash; + +pub fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { + return HashMapUnmanaged(K, V, AutoContext(K), default_max_load_percentage); +} + +pub fn AutoContext(comptime K: type) type { + return struct { + pub const hash = std.hash_map.getAutoHashFn(K, @This()); + pub const eql = std.hash_map.getAutoEqlFn(K, @This()); + }; +} + +pub const default_max_load_percentage = 80; + +/// A HashMap based on open addressing and linear probing. +/// A lookup or modification typically incurs only 2 cache misses. +/// No order is guaranteed and any modification invalidates live iterators. +/// It achieves good performance with quite high load factors (by default, +/// grow is triggered at 80% full) and only one byte of overhead per element. +/// The struct itself is only 16 bytes for a small footprint. This comes at +/// the price of handling size with u32, which should be reasonable enough +/// for almost all uses. +/// Deletions are achieved with tombstones. +pub fn HashMapUnmanaged( + comptime K: type, + comptime V: type, + comptime Context: type, + comptime max_load_percentage: u64, +) type { + if (max_load_percentage <= 0 or max_load_percentage >= 100) + @compileError("max_load_percentage must be between 0 and 100."); + return struct { + const Self = @This(); + + comptime { + std.hash_map.verifyContext(Context, K, K, u64, false); + } + + // This is actually a midway pointer to the single buffer containing + // a `Header` field, the `Metadata`s and `Entry`s. + // At `-@sizeOf(Header)` is the Header field. + // At `sizeOf(Metadata) * capacity + offset`, which is pointed to by + // self.header().entries, is the array of entries. + // This means that the hashmap only holds one live allocation, to + // reduce memory fragmentation and struct size. + /// Pointer to the metadata. + metadata: ?[*]Metadata = null, + + /// Current number of elements in the hashmap. + size: Size = 0, + + // Having a countdown to grow reduces the number of instructions to + // execute when determining if the hashmap has enough capacity already. + /// Number of available slots before a grow is needed to satisfy the + /// `max_load_percentage`. + available: Size = 0, + + // This is purely empirical and not a /very smart magic constant™/. + /// Capacity of the first grow when bootstrapping the hashmap. + const minimal_capacity = 8; + + // This hashmap is specially designed for sizes that fit in a u32. + pub const Size = u32; + + // u64 hashes guarantee us that the fingerprint bits will never be used + // to compute the index of a slot, maximizing the use of entropy. + pub const Hash = u64; + + pub const Entry = struct { + key_ptr: *K, + value_ptr: *V, + }; + + pub const KV = struct { + key: K, + value: V, + }; + + const Header = struct { + values: [*]V, + keys: [*]K, + capacity: Size, + }; + + /// Metadata for a slot. It can be in three states: empty, used or + /// tombstone. Tombstones indicate that an entry was previously used, + /// they are a simple way to handle removal. + /// To this state, we add 7 bits from the slot's key hash. These are + /// used as a fast way to disambiguate between entries without + /// having to use the equality function. If two fingerprints are + /// different, we know that we don't have to compare the keys at all. + /// The 7 bits are the highest ones from a 64 bit hash. This way, not + /// only we use the `log2(capacity)` lowest bits from the hash to determine + /// a slot index, but we use 7 more bits to quickly resolve collisions + /// when multiple elements with different hashes end up wanting to be in the same slot. + /// Not using the equality function means we don't have to read into + /// the entries array, likely avoiding a cache miss and a potentially + /// costly function call. + const Metadata = packed struct { + const FingerPrint = u7; + + const free: FingerPrint = 0; + const tombstone: FingerPrint = 1; + + fingerprint: FingerPrint = free, + used: u1 = 0, + + const slot_free = @as(u8, @bitCast(Metadata{ .fingerprint = free })); + const slot_tombstone = @as(u8, @bitCast(Metadata{ .fingerprint = tombstone })); + + pub fn isUsed(self: Metadata) bool { + return self.used == 1; + } + + pub fn isTombstone(self: Metadata) bool { + return @as(u8, @bitCast(self)) == slot_tombstone; + } + + pub fn isFree(self: Metadata) bool { + return @as(u8, @bitCast(self)) == slot_free; + } + + pub fn takeFingerprint(hash: Hash) FingerPrint { + const hash_bits = @typeInfo(Hash).Int.bits; + const fp_bits = @typeInfo(FingerPrint).Int.bits; + return @as(FingerPrint, @truncate(hash >> (hash_bits - fp_bits))); + } + + pub fn fill(self: *Metadata, fp: FingerPrint) void { + self.used = 1; + self.fingerprint = fp; + } + + pub fn remove(self: *Metadata) void { + self.used = 0; + self.fingerprint = tombstone; + } + }; + + comptime { + assert(@sizeOf(Metadata) == 1); + assert(@alignOf(Metadata) == 1); + } + + pub const Iterator = struct { + hm: *const Self, + index: Size = 0, + + pub fn next(it: *Iterator) ?Entry { + assert(it.index <= it.hm.capacity()); + if (it.hm.size == 0) return null; + + const cap = it.hm.capacity(); + const end = it.hm.metadata.? + cap; + var metadata = it.hm.metadata.? + it.index; + + while (metadata != end) : ({ + metadata += 1; + it.index += 1; + }) { + if (metadata[0].isUsed()) { + const key = &it.hm.keys()[it.index]; + const value = &it.hm.values()[it.index]; + it.index += 1; + return Entry{ .key_ptr = key, .value_ptr = value }; + } + } + + return null; + } + }; + + pub const KeyIterator = FieldIterator(K); + pub const ValueIterator = FieldIterator(V); + + fn FieldIterator(comptime T: type) type { + return struct { + len: usize, + metadata: [*]const Metadata, + items: [*]T, + + pub fn next(self: *@This()) ?*T { + while (self.len > 0) { + self.len -= 1; + const used = self.metadata[0].isUsed(); + const item = &self.items[0]; + self.metadata += 1; + self.items += 1; + if (used) { + return item; + } + } + return null; + } + }; + } + + pub const GetOrPutResult = struct { + key_ptr: *K, + value_ptr: *V, + found_existing: bool, + }; + + fn isUnderMaxLoadPercentage(size: Size, cap: Size) bool { + return size * 100 < max_load_percentage * cap; + } + + pub fn deinit(self: *Self, allocator: Allocator) void { + self.deallocate(allocator); + self.* = undefined; + } + + fn capacityForSize(size: Size) Size { + var new_cap: u32 = @truncate((@as(u64, size) * 100) / max_load_percentage + 1); + new_cap = math.ceilPowerOfTwo(u32, new_cap) catch unreachable; + return new_cap; + } + + pub fn ensureTotalCapacity(self: *Self, allocator: Allocator, new_size: Size) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call ensureTotalCapacityContext instead."); + return ensureTotalCapacityContext(self, allocator, new_size, undefined); + } + pub fn ensureTotalCapacityContext(self: *Self, allocator: Allocator, new_size: Size, ctx: Context) Allocator.Error!void { + if (new_size > self.size) + try self.growIfNeeded(allocator, new_size - self.size, ctx); + } + + pub fn ensureUnusedCapacity(self: *Self, allocator: Allocator, additional_size: Size) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call ensureUnusedCapacityContext instead."); + return ensureUnusedCapacityContext(self, allocator, additional_size, undefined); + } + pub fn ensureUnusedCapacityContext(self: *Self, allocator: Allocator, additional_size: Size, ctx: Context) Allocator.Error!void { + return ensureTotalCapacityContext(self, allocator, self.count() + additional_size, ctx); + } + + pub fn clearRetainingCapacity(self: *Self) void { + if (self.metadata) |_| { + self.initMetadatas(); + self.size = 0; + self.available = @as(u32, @truncate((self.capacity() * max_load_percentage) / 100)); + } + } + + pub fn clearAndFree(self: *Self, allocator: Allocator) void { + self.deallocate(allocator); + self.size = 0; + self.available = 0; + } + + pub fn count(self: *const Self) Size { + return self.size; + } + + fn header(self: *const Self) *Header { + return @ptrCast(@as([*]Header, @ptrCast(@alignCast(self.metadata.?))) - 1); + } + + fn keys(self: *const Self) [*]K { + return self.header().keys; + } + + fn values(self: *const Self) [*]V { + return self.header().values; + } + + pub fn capacity(self: *const Self) Size { + if (self.metadata == null) return 0; + + return self.header().capacity; + } + + pub fn iterator(self: *const Self) Iterator { + return .{ .hm = self }; + } + + pub fn keyIterator(self: *const Self) KeyIterator { + if (self.metadata) |metadata| { + return .{ + .len = self.capacity(), + .metadata = metadata, + .items = self.keys(), + }; + } else { + return .{ + .len = 0, + .metadata = undefined, + .items = undefined, + }; + } + } + + pub fn valueIterator(self: *const Self) ValueIterator { + if (self.metadata) |metadata| { + return .{ + .len = self.capacity(), + .metadata = metadata, + .items = self.values(), + }; + } else { + return .{ + .len = 0, + .metadata = undefined, + .items = undefined, + }; + } + } + + /// Insert an entry in the map. Assumes it is not already present. + pub fn putNoClobber(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); + return self.putNoClobberContext(allocator, key, value, undefined); + } + pub fn putNoClobberContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!void { + assert(!self.containsContext(key, ctx)); + try self.growIfNeeded(allocator, 1, ctx); + + self.putAssumeCapacityNoClobberContext(key, value, ctx); + } + + /// Asserts there is enough capacity to store the new key-value pair. + /// Clobbers any existing data. To detect if a put would clobber + /// existing data, see `getOrPutAssumeCapacity`. + pub fn putAssumeCapacity(self: *Self, key: K, value: V) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putAssumeCapacityContext instead."); + return self.putAssumeCapacityContext(key, value, undefined); + } + pub fn putAssumeCapacityContext(self: *Self, key: K, value: V, ctx: Context) void { + const gop = self.getOrPutAssumeCapacityContext(key, ctx); + gop.value_ptr.* = value; + } + + /// Insert an entry in the map. Assumes it is not already present, + /// and that no allocation is needed. + pub fn putAssumeCapacityNoClobber(self: *Self, key: K, value: V) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putAssumeCapacityNoClobberContext instead."); + return self.putAssumeCapacityNoClobberContext(key, value, undefined); + } + pub fn putAssumeCapacityNoClobberContext(self: *Self, key: K, value: V, ctx: Context) void { + assert(!self.containsContext(key, ctx)); + + const hash = ctx.hash(key); + const mask = self.capacity() - 1; + var idx = @as(usize, @truncate(hash & mask)); + + var metadata = self.metadata.? + idx; + while (metadata[0].isUsed()) { + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + assert(self.available > 0); + self.available -= 1; + + const fingerprint = Metadata.takeFingerprint(hash); + metadata[0].fill(fingerprint); + self.keys()[idx] = key; + self.values()[idx] = value; + + self.size += 1; + } + + /// Inserts a new `Entry` into the hash map, returning the previous one, if any. + pub fn fetchPut(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); + return self.fetchPutContext(allocator, key, value, undefined); + } + pub fn fetchPutContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!?KV { + const gop = try self.getOrPutContext(allocator, key, ctx); + var result: ?KV = null; + if (gop.found_existing) { + result = KV{ + .key = gop.key_ptr.*, + .value = gop.value_ptr.*, + }; + } + gop.value_ptr.* = value; + return result; + } + + /// Inserts a new `Entry` into the hash map, returning the previous one, if any. + /// If insertion happens, asserts there is enough capacity without allocating. + pub fn fetchPutAssumeCapacity(self: *Self, key: K, value: V) ?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutAssumeCapacityContext instead."); + return self.fetchPutAssumeCapacityContext(key, value, undefined); + } + pub fn fetchPutAssumeCapacityContext(self: *Self, key: K, value: V, ctx: Context) ?KV { + const gop = self.getOrPutAssumeCapacityContext(key, ctx); + var result: ?KV = null; + if (gop.found_existing) { + result = KV{ + .key = gop.key_ptr.*, + .value = gop.value_ptr.*, + }; + } + gop.value_ptr.* = value; + return result; + } + + /// If there is an `Entry` with a matching key, it is deleted from + /// the hash map, and then returned from this function. + pub fn fetchRemove(self: *Self, key: K) ?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchRemoveContext instead."); + return self.fetchRemoveContext(key, undefined); + } + pub fn fetchRemoveContext(self: *Self, key: K, ctx: Context) ?KV { + return self.fetchRemoveAdapted(key, ctx); + } + pub fn fetchRemoveAdapted(self: *Self, key: anytype, ctx: anytype) ?KV { + if (self.getIndex(key, ctx)) |idx| { + const old_key = &self.keys()[idx]; + const old_val = &self.values()[idx]; + const result = KV{ + .key = old_key.*, + .value = old_val.*, + }; + self.metadata.?[idx].remove(); + old_key.* = undefined; + old_val.* = undefined; + self.size -= 1; + self.available += 1; + return result; + } + + return null; + } + + /// Find the index containing the data for the given key. + /// Whether this function returns null is almost always + /// branched on after this function returns, and this function + /// returns null/not null from separate code paths. We + /// want the optimizer to remove that branch and instead directly + /// fuse the basic blocks after the branch to the basic blocks + /// from this function. To encourage that, this function is + /// marked as inline. + inline fn getIndex(self: Self, key: anytype, ctx: anytype) ?usize { + comptime std.hash_map.verifyContext(@TypeOf(ctx), @TypeOf(key), K, Hash, false); + + if (self.size == 0) { + return null; + } + + // If you get a compile error on this line, it means that your generic hash + // function is invalid for these parameters. + const hash = ctx.hash(key); + // verifyContext can't verify the return type of generic hash functions, + // so we need to double-check it here. + if (@TypeOf(hash) != Hash) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic hash function that returns the wrong type! " ++ @typeName(Hash) ++ " was expected, but found " ++ @typeName(@TypeOf(hash))); + } + const mask = self.capacity() - 1; + const fingerprint = Metadata.takeFingerprint(hash); + // Don't loop indefinitely when there are no empty slots. + var limit = self.capacity(); + var idx = @as(usize, @truncate(hash & mask)); + + var metadata = self.metadata.? + idx; + while (!metadata[0].isFree() and limit != 0) { + if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { + const test_key = &self.keys()[idx]; + // If you get a compile error on this line, it means that your generic eql + // function is invalid for these parameters. + const eql = ctx.eql(key, test_key.*); + // verifyContext can't verify the return type of generic eql functions, + // so we need to double-check it here. + if (@TypeOf(eql) != bool) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic eql function that returns the wrong type! bool was expected, but found " ++ @typeName(@TypeOf(eql))); + } + if (eql) { + return idx; + } + } + + limit -= 1; + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + return null; + } + + pub fn getEntry(self: Self, key: K) ?Entry { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getEntryContext instead."); + return self.getEntryContext(key, undefined); + } + pub fn getEntryContext(self: Self, key: K, ctx: Context) ?Entry { + return self.getEntryAdapted(key, ctx); + } + pub fn getEntryAdapted(self: Self, key: anytype, ctx: anytype) ?Entry { + if (self.getIndex(key, ctx)) |idx| { + return Entry{ + .key_ptr = &self.keys()[idx], + .value_ptr = &self.values()[idx], + }; + } + return null; + } + + /// Insert an entry if the associated key is not already present, otherwise update preexisting value. + pub fn put(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); + return self.putContext(allocator, key, value, undefined); + } + pub fn putContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!void { + const result = try self.getOrPutContext(allocator, key, ctx); + result.value_ptr.* = value; + } + + /// Get an optional pointer to the actual key associated with adapted key, if present. + pub fn getKeyPtr(self: Self, key: K) ?*K { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getKeyPtrContext instead."); + return self.getKeyPtrContext(key, undefined); + } + pub fn getKeyPtrContext(self: Self, key: K, ctx: Context) ?*K { + return self.getKeyPtrAdapted(key, ctx); + } + pub fn getKeyPtrAdapted(self: Self, key: anytype, ctx: anytype) ?*K { + if (self.getIndex(key, ctx)) |idx| { + return &self.keys()[idx]; + } + return null; + } + + /// Get a copy of the actual key associated with adapted key, if present. + pub fn getKey(self: Self, key: K) ?K { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getKeyContext instead."); + return self.getKeyContext(key, undefined); + } + pub fn getKeyContext(self: Self, key: K, ctx: Context) ?K { + return self.getKeyAdapted(key, ctx); + } + pub fn getKeyAdapted(self: Self, key: anytype, ctx: anytype) ?K { + if (self.getIndex(key, ctx)) |idx| { + return self.keys()[idx]; + } + return null; + } + + /// Get an optional pointer to the value associated with key, if present. + pub fn getPtr(self: Self, key: K) ?*V { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getPtrContext instead."); + return self.getPtrContext(key, undefined); + } + pub fn getPtrContext(self: Self, key: K, ctx: Context) ?*V { + return self.getPtrAdapted(key, ctx); + } + pub fn getPtrAdapted(self: Self, key: anytype, ctx: anytype) ?*V { + if (self.getIndex(key, ctx)) |idx| { + return &self.values()[idx]; + } + return null; + } + + /// Get a copy of the value associated with key, if present. + pub fn get(self: Self, key: K) ?V { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getContext instead."); + return self.getContext(key, undefined); + } + pub fn getContext(self: Self, key: K, ctx: Context) ?V { + return self.getAdapted(key, ctx); + } + pub fn getAdapted(self: Self, key: anytype, ctx: anytype) ?V { + if (self.getIndex(key, ctx)) |idx| { + return self.values()[idx]; + } + return null; + } + + pub fn getOrPut(self: *Self, allocator: Allocator, key: K) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); + return self.getOrPutContext(allocator, key, undefined); + } + pub fn getOrPutContext(self: *Self, allocator: Allocator, key: K, ctx: Context) Allocator.Error!GetOrPutResult { + const gop = try self.getOrPutContextAdapted(allocator, key, ctx, ctx); + if (!gop.found_existing) { + gop.key_ptr.* = key; + } + return gop; + } + pub fn getOrPutAdapted(self: *Self, allocator: Allocator, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); + return self.getOrPutContextAdapted(allocator, key, key_ctx, undefined); + } + pub fn getOrPutContextAdapted(self: *Self, allocator: Allocator, key: anytype, key_ctx: anytype, ctx: Context) Allocator.Error!GetOrPutResult { + self.growIfNeeded(allocator, 1, ctx) catch |err| { + // If allocation fails, try to do the lookup anyway. + // If we find an existing item, we can return it. + // Otherwise return the error, we could not add another. + const index = self.getIndex(key, key_ctx) orelse return err; + return GetOrPutResult{ + .key_ptr = &self.keys()[index], + .value_ptr = &self.values()[index], + .found_existing = true, + }; + }; + return self.getOrPutAssumeCapacityAdapted(key, key_ctx); + } + + pub fn getOrPutAssumeCapacity(self: *Self, key: K) GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutAssumeCapacityContext instead."); + return self.getOrPutAssumeCapacityContext(key, undefined); + } + pub fn getOrPutAssumeCapacityContext(self: *Self, key: K, ctx: Context) GetOrPutResult { + const result = self.getOrPutAssumeCapacityAdapted(key, ctx); + if (!result.found_existing) { + result.key_ptr.* = key; + } + return result; + } + pub fn getOrPutAssumeCapacityAdapted(self: *Self, key: anytype, ctx: anytype) GetOrPutResult { + comptime std.hash_map.verifyContext(@TypeOf(ctx), @TypeOf(key), K, Hash, false); + + // If you get a compile error on this line, it means that your generic hash + // function is invalid for these parameters. + const hash = ctx.hash(key); + // verifyContext can't verify the return type of generic hash functions, + // so we need to double-check it here. + if (@TypeOf(hash) != Hash) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic hash function that returns the wrong type! " ++ @typeName(Hash) ++ " was expected, but found " ++ @typeName(@TypeOf(hash))); + } + const mask = self.capacity() - 1; + const fingerprint = Metadata.takeFingerprint(hash); + var limit = self.capacity(); + var idx = @as(usize, @truncate(hash & mask)); + + var first_tombstone_idx: usize = self.capacity(); // invalid index + var metadata = self.metadata.? + idx; + while (!metadata[0].isFree() and limit != 0) { + if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { + const test_key = &self.keys()[idx]; + // If you get a compile error on this line, it means that your generic eql + // function is invalid for these parameters. + const eql = ctx.eql(key, test_key.*); + // verifyContext can't verify the return type of generic eql functions, + // so we need to double-check it here. + if (@TypeOf(eql) != bool) { + @compileError("Context " ++ @typeName(@TypeOf(ctx)) ++ " has a generic eql function that returns the wrong type! bool was expected, but found " ++ @typeName(@TypeOf(eql))); + } + if (eql) { + return GetOrPutResult{ + .key_ptr = test_key, + .value_ptr = &self.values()[idx], + .found_existing = true, + }; + } + } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) { + first_tombstone_idx = idx; + } + + limit -= 1; + idx = (idx + 1) & mask; + metadata = self.metadata.? + idx; + } + + if (first_tombstone_idx < self.capacity()) { + // Cheap try to lower probing lengths after deletions. Recycle a tombstone. + idx = first_tombstone_idx; + metadata = self.metadata.? + idx; + } + // We're using a slot previously free or a tombstone. + self.available -= 1; + + metadata[0].fill(fingerprint); + const new_key = &self.keys()[idx]; + const new_value = &self.values()[idx]; + new_key.* = undefined; + new_value.* = undefined; + self.size += 1; + + return GetOrPutResult{ + .key_ptr = new_key, + .value_ptr = new_value, + .found_existing = false, + }; + } + + pub fn getOrPutValue(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!Entry { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); + return self.getOrPutValueContext(allocator, key, value, undefined); + } + pub fn getOrPutValueContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!Entry { + const res = try self.getOrPutAdapted(allocator, key, ctx); + if (!res.found_existing) { + res.key_ptr.* = key; + res.value_ptr.* = value; + } + return Entry{ .key_ptr = res.key_ptr, .value_ptr = res.value_ptr }; + } + + /// Return true if there is a value associated with key in the map. + pub fn contains(self: *const Self, key: K) bool { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call containsContext instead."); + return self.containsContext(key, undefined); + } + pub fn containsContext(self: *const Self, key: K, ctx: Context) bool { + return self.containsAdapted(key, ctx); + } + pub fn containsAdapted(self: *const Self, key: anytype, ctx: anytype) bool { + return self.getIndex(key, ctx) != null; + } + + fn removeByIndex(self: *Self, idx: usize) void { + self.metadata.?[idx].remove(); + self.keys()[idx] = undefined; + self.values()[idx] = undefined; + self.size -= 1; + self.available += 1; + } + + /// If there is an `Entry` with a matching key, it is deleted from + /// the hash map, and this function returns true. Otherwise this + /// function returns false. + pub fn remove(self: *Self, key: K) bool { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeContext instead."); + return self.removeContext(key, undefined); + } + pub fn removeContext(self: *Self, key: K, ctx: Context) bool { + return self.removeAdapted(key, ctx); + } + pub fn removeAdapted(self: *Self, key: anytype, ctx: anytype) bool { + if (self.getIndex(key, ctx)) |idx| { + self.removeByIndex(idx); + return true; + } + + return false; + } + + /// Delete the entry with key pointed to by key_ptr from the hash map. + /// key_ptr is assumed to be a valid pointer to a key that is present + /// in the hash map. + pub fn removeByPtr(self: *Self, key_ptr: *K) void { + // TODO: replace with pointer subtraction once supported by zig + // if @sizeOf(K) == 0 then there is at most one item in the hash + // map, which is assumed to exist as key_ptr must be valid. This + // item must be at index 0. + const idx = if (@sizeOf(K) > 0) + (@intFromPtr(key_ptr) - @intFromPtr(self.keys())) / @sizeOf(K) + else + 0; + + self.removeByIndex(idx); + } + + fn initMetadatas(self: *Self) void { + @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); + } + + // This counts the number of occupied slots (not counting tombstones), which is + // what has to stay under the max_load_percentage of capacity. + fn load(self: *const Self) Size { + const max_load = (self.capacity() * max_load_percentage) / 100; + assert(max_load >= self.available); + return @as(Size, @truncate(max_load - self.available)); + } + + fn growIfNeeded(self: *Self, allocator: Allocator, new_count: Size, ctx: Context) Allocator.Error!void { + if (new_count > self.available) { + try self.grow(allocator, capacityForSize(self.load() + new_count), ctx); + } + } + + pub fn clone(self: Self, allocator: Allocator) Allocator.Error!Self { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call cloneContext instead."); + return self.cloneContext(allocator, @as(Context, undefined)); + } + pub fn cloneContext(self: Self, allocator: Allocator, new_ctx: anytype) Allocator.Error!HashMapUnmanaged(K, V, @TypeOf(new_ctx), max_load_percentage) { + var other = HashMapUnmanaged(K, V, @TypeOf(new_ctx), max_load_percentage){}; + if (self.size == 0) + return other; + + const new_cap = capacityForSize(self.size); + try other.allocate(allocator, new_cap); + other.initMetadatas(); + other.available = @truncate((new_cap * max_load_percentage) / 100); + + var i: Size = 0; + var metadata = self.metadata.?; + const keys_ptr = self.keys(); + const values_ptr = self.values(); + while (i < self.capacity()) : (i += 1) { + if (metadata[i].isUsed()) { + other.putAssumeCapacityNoClobberContext(keys_ptr[i], values_ptr[i], new_ctx); + if (other.size == self.size) + break; + } + } + + return other; + } + + /// Set the map to an empty state, making deinitialization a no-op, and + /// returning a copy of the original. + pub fn move(self: *Self) Self { + const result = self.*; + self.* = .{}; + return result; + } + + fn grow(self: *Self, allocator: Allocator, new_capacity: Size, ctx: Context) Allocator.Error!void { + @setCold(true); + const new_cap = @max(new_capacity, minimal_capacity); + assert(new_cap > self.capacity()); + assert(std.math.isPowerOfTwo(new_cap)); + + var map = Self{}; + defer map.deinit(allocator); + try map.allocate(allocator, new_cap); + map.initMetadatas(); + map.available = @truncate((new_cap * max_load_percentage) / 100); + + if (self.size != 0) { + const old_capacity = self.capacity(); + var i: Size = 0; + var metadata = self.metadata.?; + const keys_ptr = self.keys(); + const values_ptr = self.values(); + while (i < old_capacity) : (i += 1) { + if (metadata[i].isUsed()) { + map.putAssumeCapacityNoClobberContext(keys_ptr[i], values_ptr[i], ctx); + if (map.size == self.size) + break; + } + } + } + + self.size = 0; + std.mem.swap(Self, self, &map); + } + + fn allocate(self: *Self, allocator: Allocator, new_capacity: Size) Allocator.Error!void { + const header_align = @alignOf(Header); + const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); + const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); + const max_align = comptime @max(header_align, key_align, val_align); + + const meta_size = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + comptime assert(@alignOf(Metadata) == 1); + + const keys_start = std.mem.alignForward(usize, meta_size, key_align); + const keys_end = keys_start + new_capacity * @sizeOf(K); + + const vals_start = std.mem.alignForward(usize, keys_end, val_align); + const vals_end = vals_start + new_capacity * @sizeOf(V); + + const total_size = std.mem.alignForward(usize, vals_end, max_align); + + const slice = try allocator.alignedAlloc(u8, max_align, total_size); + const ptr = @intFromPtr(slice.ptr); + + const metadata = ptr + @sizeOf(Header); + + const hdr = @as(*Header, @ptrFromInt(ptr)); + if (@sizeOf([*]V) != 0) { + hdr.values = @as([*]V, @ptrFromInt(ptr + vals_start)); + } + if (@sizeOf([*]K) != 0) { + hdr.keys = @as([*]K, @ptrFromInt(ptr + keys_start)); + } + hdr.capacity = new_capacity; + self.metadata = @as([*]Metadata, @ptrFromInt(metadata)); + } + + fn deallocate(self: *Self, allocator: Allocator) void { + if (self.metadata == null) return; + + const header_align = @alignOf(Header); + const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); + const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); + const max_align = comptime @max(header_align, key_align, val_align); + + const cap = self.capacity(); + const meta_size = @sizeOf(Header) + cap * @sizeOf(Metadata); + comptime assert(@alignOf(Metadata) == 1); + + const keys_start = std.mem.alignForward(usize, meta_size, key_align); + const keys_end = keys_start + cap * @sizeOf(K); + + const vals_start = std.mem.alignForward(usize, keys_end, val_align); + const vals_end = vals_start + cap * @sizeOf(V); + + const total_size = std.mem.alignForward(usize, vals_end, max_align); + + const slice = @as([*]align(max_align) u8, @ptrFromInt(@intFromPtr(self.header())))[0..total_size]; + allocator.free(slice); + + self.metadata = null; + self.available = 0; + } + + /// This function is used in the debugger pretty formatters in tools/ to fetch the + /// header type to facilitate fancy debug printing for this type. + fn dbHelper(self: *Self, hdr: *Header, entry: *Entry) void { + _ = self; + _ = hdr; + _ = entry; + } + + comptime { + if (builtin.mode == .Debug) { + _ = &dbHelper; + } + } + }; +} + +const testing = std.testing; +const expect = std.testing.expect; +const expectEqual = std.testing.expectEqual; + +test "std.hash_map basic usage" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + const count = 5; + var i: u32 = 0; + var total: u32 = 0; + while (i < count) : (i += 1) { + try map.put(alloc, i, i); + total += i; + } + + var sum: u32 = 0; + var it = map.iterator(); + while (it.next()) |kv| { + sum += kv.key_ptr.*; + } + try expectEqual(total, sum); + + i = 0; + sum = 0; + while (i < count) : (i += 1) { + try expectEqual(i, map.get(i).?); + sum += map.get(i).?; + } + try expectEqual(total, sum); +} + +test "std.hash_map ensureTotalCapacity" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(i32, i32) = .{}; + defer map.deinit(alloc); + + try map.ensureTotalCapacity(alloc, 20); + const initial_capacity = map.capacity(); + try testing.expect(initial_capacity >= 20); + var i: i32 = 0; + while (i < 20) : (i += 1) { + try testing.expect(map.fetchPutAssumeCapacity(i, i + 10) == null); + } + // shouldn't resize from putAssumeCapacity + try testing.expect(initial_capacity == map.capacity()); +} + +test "std.hash_map ensureUnusedCapacity with tombstones" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(i32, i32) = .{}; + defer map.deinit(alloc); + + var i: i32 = 0; + while (i < 100) : (i += 1) { + try map.ensureUnusedCapacity(alloc, 1); + map.putAssumeCapacity(i, i); + _ = map.remove(i); + } +} + +test "std.hash_map clearRetainingCapacity" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + map.clearRetainingCapacity(); + + try map.put(alloc, 1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + map.clearRetainingCapacity(); + map.putAssumeCapacity(1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + const cap = map.capacity(); + try expect(cap > 0); + + map.clearRetainingCapacity(); + map.clearRetainingCapacity(); + try expectEqual(map.count(), 0); + try expectEqual(map.capacity(), cap); + try expect(!map.contains(1)); +} + +test "std.hash_map grow" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + const growTo = 12456; + + var i: u32 = 0; + while (i < growTo) : (i += 1) { + try map.put(alloc, i, i); + } + try expectEqual(map.count(), growTo); + + i = 0; + var it = map.iterator(); + while (it.next()) |kv| { + try expectEqual(kv.key_ptr.*, kv.value_ptr.*); + i += 1; + } + try expectEqual(i, growTo); + + i = 0; + while (i < growTo) : (i += 1) { + try expectEqual(map.get(i).?, i); + } +} + +test "std.hash_map clone" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var a = try map.clone(alloc); + defer a.deinit(alloc); + + try expectEqual(a.count(), 0); + + try a.put(alloc, 1, 1); + try a.put(alloc, 2, 2); + try a.put(alloc, 3, 3); + + var b = try a.clone(alloc); + defer b.deinit(alloc); + + try expectEqual(b.count(), 3); + try expectEqual(b.get(1).?, 1); + try expectEqual(b.get(2).?, 2); + try expectEqual(b.get(3).?, 3); + + var original: AutoHashMapUnmanaged(i32, i32) = .{}; + defer original.deinit(alloc); + + var i: u8 = 0; + while (i < 10) : (i += 1) { + try original.putNoClobber(alloc, i, i * 10); + } + + var copy = try original.clone(alloc); + defer copy.deinit(alloc); + + i = 0; + while (i < 10) : (i += 1) { + try testing.expect(copy.get(i).? == i * 10); + } +} + +test "std.hash_map ensureTotalCapacity with existing elements" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + try map.put(alloc, 0, 0); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), @TypeOf(map).minimal_capacity); + + try map.ensureTotalCapacity(alloc, 65); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), 128); +} + +test "std.hash_map ensureTotalCapacity satisfies max load factor" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + try map.ensureTotalCapacity(alloc, 127); + try expectEqual(map.capacity(), 256); +} + +test "std.hash_map remove" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(alloc, i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + _ = map.remove(i); + } + } + try expectEqual(map.count(), 10); + var it = map.iterator(); + while (it.next()) |kv| { + try expectEqual(kv.key_ptr.*, kv.value_ptr.*); + try expect(kv.key_ptr.* % 3 != 0); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } +} + +test "std.hash_map reverse removes" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.putNoClobber(alloc, i, i); + } + + i = 16; + while (i > 0) : (i -= 1) { + _ = map.remove(i - 1); + try expect(!map.contains(i - 1)); + var j: u32 = 0; + while (j < i - 1) : (j += 1) { + try expectEqual(map.get(j).?, j); + } + } + + try expectEqual(map.count(), 0); +} + +test "std.hash_map multiple removes on same metadata" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(alloc, i, i); + } + + _ = map.remove(7); + _ = map.remove(15); + _ = map.remove(14); + _ = map.remove(13); + try expect(!map.contains(7)); + try expect(!map.contains(15)); + try expect(!map.contains(14)); + try expect(!map.contains(13)); + + i = 0; + while (i < 13) : (i += 1) { + if (i == 7) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } + + try map.put(alloc, 15, 15); + try map.put(alloc, 13, 13); + try map.put(alloc, 14, 14); + try map.put(alloc, 7, 7); + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } +} + +test "std.hash_map put and remove loop in random order" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var keys = std.ArrayList(u32).init(std.testing.allocator); + defer keys.deinit(); + + const size = 32; + const iterations = 100; + + var i: u32 = 0; + while (i < size) : (i += 1) { + try keys.append(i); + } + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + + while (i < iterations) : (i += 1) { + random.shuffle(u32, keys.items); + + for (keys.items) |key| { + try map.put(alloc, key, key); + } + try expectEqual(map.count(), size); + + for (keys.items) |key| { + _ = map.remove(key); + } + try expectEqual(map.count(), 0); + } +} + +test "std.hash_map remove one million elements in random order" { + const alloc = testing.allocator; + const n = 1000 * 1000; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var keys = std.ArrayList(u32).init(std.heap.page_allocator); + defer keys.deinit(); + + var i: u32 = 0; + while (i < n) : (i += 1) { + keys.append(i) catch unreachable; + } + + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + random.shuffle(u32, keys.items); + + for (keys.items) |key| { + map.put(alloc, key, key) catch unreachable; + } + + random.shuffle(u32, keys.items); + i = 0; + while (i < n) : (i += 1) { + const key = keys.items[i]; + _ = map.remove(key); + } +} + +test "std.hash_map put" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put(alloc, i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try map.put(alloc, i, i * 16 + 1); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i * 16 + 1); + } +} + +test "std.hash_map putAssumeCapacity" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + try map.ensureTotalCapacity(alloc, 20); + var i: u32 = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.getPtr(i).?.*; + } + try expectEqual(sum, 190); + + i = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacity(i, 1); + } + + i = 0; + sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + try expectEqual(sum, 20); +} + +test "std.hash_map repeat putAssumeCapacity/remove" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + try map.ensureTotalCapacity(alloc, 20); + const limit = map.available; + + var i: u32 = 0; + while (i < limit) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + // Repeatedly delete/insert an entry without resizing the map. + // Put to different keys so entries don't land in the just-freed slot. + i = 0; + while (i < 10 * limit) : (i += 1) { + try testing.expect(map.remove(i)); + if (i % 2 == 0) { + map.putAssumeCapacityNoClobber(limit + i, i); + } else { + map.putAssumeCapacity(limit + i, i); + } + } + + i = 9 * limit; + while (i < 10 * limit) : (i += 1) { + try expectEqual(map.get(limit + i), i); + } + try expectEqual(map.available, 0); + try expectEqual(map.count(), limit); +} + +test "std.hash_map getOrPut" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u32, u32) = .{}; + defer map.deinit(alloc); + + var i: u32 = 0; + while (i < 10) : (i += 1) { + try map.put(alloc, i * 2, 2); + } + + i = 0; + while (i < 20) : (i += 1) { + _ = try map.getOrPutValue(alloc, i, 1); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + + try expectEqual(sum, 30); +} + +test "std.hash_map basic hash map usage" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(i32, i32) = .{}; + defer map.deinit(alloc); + + try testing.expect((try map.fetchPut(alloc, 1, 11)) == null); + try testing.expect((try map.fetchPut(alloc, 2, 22)) == null); + try testing.expect((try map.fetchPut(alloc, 3, 33)) == null); + try testing.expect((try map.fetchPut(alloc, 4, 44)) == null); + + try map.putNoClobber(alloc, 5, 55); + try testing.expect((try map.fetchPut(alloc, 5, 66)).?.value == 55); + try testing.expect((try map.fetchPut(alloc, 5, 55)).?.value == 66); + + const gop1 = try map.getOrPut(alloc, 5); + try testing.expect(gop1.found_existing == true); + try testing.expect(gop1.value_ptr.* == 55); + gop1.value_ptr.* = 77; + try testing.expect(map.getEntry(5).?.value_ptr.* == 77); + + const gop2 = try map.getOrPut(alloc, 99); + try testing.expect(gop2.found_existing == false); + gop2.value_ptr.* = 42; + try testing.expect(map.getEntry(99).?.value_ptr.* == 42); + + const gop3 = try map.getOrPutValue(alloc, 5, 5); + try testing.expect(gop3.value_ptr.* == 77); + + const gop4 = try map.getOrPutValue(alloc, 100, 41); + try testing.expect(gop4.value_ptr.* == 41); + + try testing.expect(map.contains(2)); + try testing.expect(map.getEntry(2).?.value_ptr.* == 22); + try testing.expect(map.get(2).? == 22); + + const rmv1 = map.fetchRemove(2); + try testing.expect(rmv1.?.key == 2); + try testing.expect(rmv1.?.value == 22); + try testing.expect(map.fetchRemove(2) == null); + try testing.expect(map.remove(2) == false); + try testing.expect(map.getEntry(2) == null); + try testing.expect(map.get(2) == null); + + try testing.expect(map.remove(3) == true); +} + +test "std.hash_map ensureUnusedCapacity" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u64, u64) = .{}; + defer map.deinit(alloc); + + try map.ensureUnusedCapacity(alloc, 32); + const capacity = map.capacity(); + try map.ensureUnusedCapacity(alloc, 32); + + // Repeated ensureUnusedCapacity() calls with no insertions between + // should not change the capacity. + try testing.expectEqual(capacity, map.capacity()); +} + +test "std.hash_map removeByPtr" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(i32, u64) = .{}; + defer map.deinit(alloc); + + var i: i32 = undefined; + + i = 0; + while (i < 10) : (i += 1) { + try map.put(alloc, i, 0); + } + + try testing.expect(map.count() == 10); + + i = 0; + while (i < 10) : (i += 1) { + const key_ptr = map.getKeyPtr(i); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + } + + try testing.expect(map.count() == 0); +} + +test "std.hash_map removeByPtr 0 sized key" { + const alloc = testing.allocator; + var map: AutoHashMapUnmanaged(u0, u64) = .{}; + defer map.deinit(alloc); + + try map.put(alloc, 0, 0); + + try testing.expect(map.count() == 1); + + const key_ptr = map.getKeyPtr(0); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + + try testing.expect(map.count() == 0); +} + +test "std.hash_map repeat fetchRemove" { + const alloc = testing.allocator; + var map = AutoHashMapUnmanaged(u64, void){}; + defer map.deinit(alloc); + + try map.ensureTotalCapacity(alloc, 4); + + map.putAssumeCapacity(0, {}); + map.putAssumeCapacity(1, {}); + map.putAssumeCapacity(2, {}); + map.putAssumeCapacity(3, {}); + + // fetchRemove() should make slots available. + var i: usize = 0; + while (i < 10) : (i += 1) { + try testing.expect(map.fetchRemove(3) != null); + map.putAssumeCapacity(3, {}); + } + + try testing.expect(map.get(0) != null); + try testing.expect(map.get(1) != null); + try testing.expect(map.get(2) != null); + try testing.expect(map.get(3) != null); +} From 11e01ab599bc29a667b45ee83a6de49c0ea54505 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 18:01:36 -0800 Subject: [PATCH 003/428] terminal/new: forked hash map works with fixed buffers --- src/terminal/new/hash_map.zig | 736 +++++++++++++++++++++++++++++++++- src/terminal/new/size.zig | 5 +- 2 files changed, 725 insertions(+), 16 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 119e3e28aa..90ea3aa7f0 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -69,6 +69,7 @@ pub fn HashMapUnmanaged( ) type { if (max_load_percentage <= 0 or max_load_percentage >= 100) @compileError("max_load_percentage must be between 0 and 100."); + return struct { const Self = @This(); @@ -76,6 +77,11 @@ pub fn HashMapUnmanaged( std.hash_map.verifyContext(Context, K, K, u64, false); } + const header_align = @alignOf(Header); + const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); + const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); + const max_align = @max(header_align, key_align, val_align); + // This is actually a midway pointer to the single buffer containing // a `Header` field, the `Metadata`s and `Entry`s. // At `-@sizeOf(Header)` is the Header field. @@ -245,12 +251,38 @@ pub fn HashMapUnmanaged( return size * 100 < max_load_percentage * cap; } + /// Initialize a hash map with a given capacity and a buffer. The + /// buffer must fit within the size defined by `layoutForCapacity`. + pub fn init(new_capacity: Size, buf: []u8) Self { + const layout = layoutForCapacity(new_capacity); + + // Ensure our base pointer is aligned to the max alignment + const base = std.mem.alignForward(usize, @intFromPtr(buf.ptr), max_align); + assert(base >= layout.total_size); + + // Get all our main pointers + const metadata_ptr: [*]Metadata = @ptrFromInt(base + @sizeOf(Header)); + const keys_ptr: [*]K = @ptrFromInt(base + layout.keys_start); + const values_ptr: [*]V = @ptrFromInt(base + layout.vals_start); + + // Build our map + var map: Self = .{ .metadata = metadata_ptr }; + const hdr = map.header(); + hdr.capacity = new_capacity; + if (@sizeOf([*]K) != 0) hdr.keys = keys_ptr; + if (@sizeOf([*]V) != 0) hdr.values = values_ptr; + map.initMetadatas(); + map.available = @truncate((new_capacity * max_load_percentage) / 100); + + return map; + } + pub fn deinit(self: *Self, allocator: Allocator) void { self.deallocate(allocator); self.* = undefined; } - fn capacityForSize(size: Size) Size { + pub fn capacityForSize(size: Size) Size { var new_cap: u32 = @truncate((@as(u64, size) * 100) / max_load_percentage + 1); new_cap = math.ceilPowerOfTwo(u32, new_cap) catch unreachable; return new_cap; @@ -265,6 +297,9 @@ pub fn HashMapUnmanaged( if (new_size > self.size) try self.growIfNeeded(allocator, new_size - self.size, ctx); } + pub fn ensureTotalCapacity2(self: *Self, new_size: Size) Allocator.Error!void { + if (new_size > self.size) try self.growIfNeeded2(new_size - self.size); + } pub fn ensureUnusedCapacity(self: *Self, allocator: Allocator, additional_size: Size) Allocator.Error!void { if (@sizeOf(Context) != 0) @@ -274,6 +309,9 @@ pub fn HashMapUnmanaged( pub fn ensureUnusedCapacityContext(self: *Self, allocator: Allocator, additional_size: Size, ctx: Context) Allocator.Error!void { return ensureTotalCapacityContext(self, allocator, self.count() + additional_size, ctx); } + pub fn ensureUnusedCapacity2(self: *Self, additional_size: Size) Allocator.Error!void { + return ensureTotalCapacity2(self, self.count() + additional_size); + } pub fn clearRetainingCapacity(self: *Self) void { if (self.metadata) |_| { @@ -359,6 +397,17 @@ pub fn HashMapUnmanaged( self.putAssumeCapacityNoClobberContext(key, value, ctx); } + pub fn putNoClobber2(self: *Self, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); + return self.putNoClobberContext2(key, value, undefined); + } + pub fn putNoClobberContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + assert(!self.containsContext(key, ctx)); + try self.growIfNeeded2(1); + + self.putAssumeCapacityNoClobberContext(key, value, ctx); + } /// Asserts there is enough capacity to store the new key-value pair. /// Clobbers any existing data. To detect if a put would clobber @@ -422,6 +471,23 @@ pub fn HashMapUnmanaged( gop.value_ptr.* = value; return result; } + pub fn fetchPut2(self: *Self, key: K, value: V) Allocator.Error!?KV { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); + return self.fetchPutContext2(key, value, undefined); + } + pub fn fetchPutContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!?KV { + const gop = try self.getOrPutContext2(key, ctx); + var result: ?KV = null; + if (gop.found_existing) { + result = KV{ + .key = gop.key_ptr.*, + .value = gop.value_ptr.*, + }; + } + gop.value_ptr.* = value; + return result; + } /// Inserts a new `Entry` into the hash map, returning the previous one, if any. /// If insertion happens, asserts there is enough capacity without allocating. @@ -545,6 +611,16 @@ pub fn HashMapUnmanaged( } /// Insert an entry if the associated key is not already present, otherwise update preexisting value. + pub fn put2(self: *Self, key: K, value: V) Allocator.Error!void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); + return self.putContext2(key, value, undefined); + } + pub fn putContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + const result = try self.getOrPutContext2(key, ctx); + result.value_ptr.* = value; + } + pub fn put(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!void { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); @@ -631,6 +707,18 @@ pub fn HashMapUnmanaged( } return gop; } + pub fn getOrPut2(self: *Self, key: K) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); + return self.getOrPutContext2(key, undefined); + } + pub fn getOrPutContext2(self: *Self, key: K, ctx: Context) Allocator.Error!GetOrPutResult { + const gop = try self.getOrPutContextAdapted2(key, ctx); + if (!gop.found_existing) { + gop.key_ptr.* = key; + } + return gop; + } pub fn getOrPutAdapted(self: *Self, allocator: Allocator, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); @@ -650,6 +738,25 @@ pub fn HashMapUnmanaged( }; return self.getOrPutAssumeCapacityAdapted(key, key_ctx); } + pub fn getOrPutAdapted2(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); + return self.getOrPutContextAdapted2(key, key_ctx); + } + pub fn getOrPutContextAdapted2(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + self.growIfNeeded2(1) catch |err| { + // If allocation fails, try to do the lookup anyway. + // If we find an existing item, we can return it. + // Otherwise return the error, we could not add another. + const index = self.getIndex(key, key_ctx) orelse return err; + return GetOrPutResult{ + .key_ptr = &self.keys()[index], + .value_ptr = &self.values()[index], + .found_existing = true, + }; + }; + return self.getOrPutAssumeCapacityAdapted(key, key_ctx); + } pub fn getOrPutAssumeCapacity(self: *Self, key: K) GetOrPutResult { if (@sizeOf(Context) != 0) @@ -743,6 +850,19 @@ pub fn HashMapUnmanaged( } return Entry{ .key_ptr = res.key_ptr, .value_ptr = res.value_ptr }; } + pub fn getOrPutValue2(self: *Self, key: K, value: V) Allocator.Error!Entry { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); + return self.getOrPutValueContext2(key, value, undefined); + } + pub fn getOrPutValueContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!Entry { + const res = try self.getOrPutAdapted2(key, ctx); + if (!res.found_existing) { + res.key_ptr.* = key; + res.value_ptr.* = value; + } + return Entry{ .key_ptr = res.key_ptr, .value_ptr = res.value_ptr }; + } /// Return true if there is a value associated with key in the map. pub fn contains(self: *const Self, key: K) bool { @@ -818,6 +938,9 @@ pub fn HashMapUnmanaged( try self.grow(allocator, capacityForSize(self.load() + new_count), ctx); } } + fn growIfNeeded2(self: *Self, new_count: Size) Allocator.Error!void { + if (new_count > self.available) return error.OutOfMemory; + } pub fn clone(self: Self, allocator: Allocator) Allocator.Error!Self { if (@sizeOf(Context) != 0) @@ -888,12 +1011,25 @@ pub fn HashMapUnmanaged( std.mem.swap(Self, self, &map); } - fn allocate(self: *Self, allocator: Allocator, new_capacity: Size) Allocator.Error!void { - const header_align = @alignOf(Header); - const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); - const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); - const max_align = comptime @max(header_align, key_align, val_align); + /// The memory layout for the underlying buffer for a given capacity. + pub const Layout = struct { + /// The total size of the buffer required. The buffer is expected + /// to be aligned to `max_align`. + total_size: usize, + + /// The offset to the start of the keys data. + keys_start: usize, + + /// The offset to the start of the values data. + vals_start: usize, + }; + /// Returns the memory layout for the buffer for a given capacity. + /// The actual size may be able to fit more than the given capacity + /// because capacity is rounded up to the next power of two. This is + /// a design requirement for this hash map implementation. + pub fn layoutForCapacity(new_capacity: Size) Layout { + assert(std.math.isPowerOfTwo(new_capacity)); const meta_size = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); comptime assert(@alignOf(Metadata) == 1); @@ -904,18 +1040,26 @@ pub fn HashMapUnmanaged( const vals_end = vals_start + new_capacity * @sizeOf(V); const total_size = std.mem.alignForward(usize, vals_end, max_align); + return .{ + .total_size = total_size, + .keys_start = keys_start, + .vals_start = vals_start, + }; + } - const slice = try allocator.alignedAlloc(u8, max_align, total_size); + fn allocate(self: *Self, allocator: Allocator, new_capacity: Size) Allocator.Error!void { + const layout = layoutForCapacity(new_capacity); + const slice = try allocator.alignedAlloc(u8, max_align, layout.total_size); const ptr = @intFromPtr(slice.ptr); const metadata = ptr + @sizeOf(Header); const hdr = @as(*Header, @ptrFromInt(ptr)); if (@sizeOf([*]V) != 0) { - hdr.values = @as([*]V, @ptrFromInt(ptr + vals_start)); + hdr.values = @as([*]V, @ptrFromInt(ptr + layout.vals_start)); } if (@sizeOf([*]K) != 0) { - hdr.keys = @as([*]K, @ptrFromInt(ptr + keys_start)); + hdr.keys = @as([*]K, @ptrFromInt(ptr + layout.keys_start)); } hdr.capacity = new_capacity; self.metadata = @as([*]Metadata, @ptrFromInt(metadata)); @@ -924,11 +1068,6 @@ pub fn HashMapUnmanaged( fn deallocate(self: *Self, allocator: Allocator) void { if (self.metadata == null) return; - const header_align = @alignOf(Header); - const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); - const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); - const max_align = comptime @max(header_align, key_align, val_align); - const cap = self.capacity(); const meta_size = @sizeOf(Header) + cap * @sizeOf(Metadata); comptime assert(@alignOf(Metadata) == 1); @@ -1542,3 +1681,572 @@ test "std.hash_map repeat fetchRemove" { try testing.expect(map.get(2) != null); try testing.expect(map.get(3) != null); } + +//------------------------------------------------------------------- +// New tests + +test "HashMap basic usage" { + const Map = AutoHashMapUnmanaged(u32, u32); + + const alloc = testing.allocator; + const cap = 16; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + + var map = Map.init(cap, buf); + + const count = 5; + var i: u32 = 0; + var total: u32 = 0; + while (i < count) : (i += 1) { + try map.put2(i, i); + total += i; + } + + var sum: u32 = 0; + var it = map.iterator(); + while (it.next()) |kv| { + sum += kv.key_ptr.*; + } + try expectEqual(total, sum); + + i = 0; + sum = 0; + while (i < count) : (i += 1) { + try expectEqual(i, map.get(i).?); + sum += map.get(i).?; + } + try expectEqual(total, sum); +} + +test "HashMap ensureTotalCapacity" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + const initial_capacity = map.capacity(); + try testing.expect(initial_capacity >= 20); + var i: i32 = 0; + while (i < 20) : (i += 1) { + try testing.expect(map.fetchPutAssumeCapacity(i, i + 10) == null); + } + // shouldn't resize from putAssumeCapacity + try testing.expect(initial_capacity == map.capacity()); +} + +test "HashMap ensureUnusedCapacity with tombstones" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: i32 = 0; + while (i < 100) : (i += 1) { + try map.ensureUnusedCapacity2(1); + map.putAssumeCapacity(i, i); + _ = map.remove(i); + } +} + +test "HashMap clearRetainingCapacity" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 16; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + map.clearRetainingCapacity(); + + try map.put2(1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + map.clearRetainingCapacity(); + map.putAssumeCapacity(1, 1); + try expectEqual(map.get(1).?, 1); + try expectEqual(map.count(), 1); + + const actual_cap = map.capacity(); + try expect(actual_cap > 0); + + map.clearRetainingCapacity(); + map.clearRetainingCapacity(); + try expectEqual(map.count(), 0); + try expectEqual(map.capacity(), actual_cap); + try expect(!map.contains(1)); +} + +test "HashMap ensureTotalCapacity with existing elements" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = Map.minimal_capacity; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + try map.put2(0, 0); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), Map.minimal_capacity); + + try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity2(65)); + try expectEqual(map.count(), 1); + try expectEqual(map.capacity(), Map.minimal_capacity); +} + +test "HashMap remove" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put2(i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + _ = map.remove(i); + } + } + try expectEqual(map.count(), 10); + var it = map.iterator(); + while (it.next()) |kv| { + try expectEqual(kv.key_ptr.*, kv.value_ptr.*); + try expect(kv.key_ptr.* % 3 != 0); + } + + i = 0; + while (i < 16) : (i += 1) { + if (i % 3 == 0) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } +} + +test "HashMap reverse removes" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.putNoClobber2(i, i); + } + + i = 16; + while (i > 0) : (i -= 1) { + _ = map.remove(i - 1); + try expect(!map.contains(i - 1)); + var j: u32 = 0; + while (j < i - 1) : (j += 1) { + try expectEqual(map.get(j).?, j); + } + } + + try expectEqual(map.count(), 0); +} + +test "HashMap multiple removes on same metadata" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put2(i, i); + } + + _ = map.remove(7); + _ = map.remove(15); + _ = map.remove(14); + _ = map.remove(13); + try expect(!map.contains(7)); + try expect(!map.contains(15)); + try expect(!map.contains(14)); + try expect(!map.contains(13)); + + i = 0; + while (i < 13) : (i += 1) { + if (i == 7) { + try expect(!map.contains(i)); + } else { + try expectEqual(map.get(i).?, i); + } + } + + try map.put2(15, 15); + try map.put2(13, 13); + try map.put2(14, 14); + try map.put2(7, 7); + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } +} + +test "HashMap put and remove loop in random order" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 64; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var keys = std.ArrayList(u32).init(alloc); + defer keys.deinit(); + + const size = 32; + const iterations = 100; + + var i: u32 = 0; + while (i < size) : (i += 1) { + try keys.append(i); + } + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + + while (i < iterations) : (i += 1) { + random.shuffle(u32, keys.items); + + for (keys.items) |key| { + try map.put2(key, key); + } + try expectEqual(map.count(), size); + + for (keys.items) |key| { + _ = map.remove(key); + } + try expectEqual(map.count(), 0); + } +} + +test "HashMap remove one million elements in random order" { + const Map = AutoHashMapUnmanaged(u32, u32); + const n = 1000 * 1000; + const cap = Map.capacityForSize(n); + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var keys = std.ArrayList(u32).init(alloc); + defer keys.deinit(); + + var i: u32 = 0; + while (i < n) : (i += 1) { + keys.append(i) catch unreachable; + } + + var prng = std.Random.DefaultPrng.init(0); + const random = prng.random(); + random.shuffle(u32, keys.items); + + for (keys.items) |key| { + map.put2(key, key) catch unreachable; + } + + random.shuffle(u32, keys.items); + i = 0; + while (i < n) : (i += 1) { + const key = keys.items[i]; + _ = map.remove(key); + } +} + +test "HashMap put" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 16) : (i += 1) { + try map.put2(i, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i); + } + + i = 0; + while (i < 16) : (i += 1) { + try map.put2(i, i * 16 + 1); + } + + i = 0; + while (i < 16) : (i += 1) { + try expectEqual(map.get(i).?, i * 16 + 1); + } +} + +test "HashMap putAssumeCapacity" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.getPtr(i).?.*; + } + try expectEqual(sum, 190); + + i = 0; + while (i < 20) : (i += 1) { + map.putAssumeCapacity(i, 1); + } + + i = 0; + sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + try expectEqual(sum, 20); +} + +test "HashMap repeat putAssumeCapacity/remove" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + const limit = map.available; + + var i: u32 = 0; + while (i < limit) : (i += 1) { + map.putAssumeCapacityNoClobber(i, i); + } + + // Repeatedly delete/insert an entry without resizing the map. + // Put to different keys so entries don't land in the just-freed slot. + i = 0; + while (i < 10 * limit) : (i += 1) { + try testing.expect(map.remove(i)); + if (i % 2 == 0) { + map.putAssumeCapacityNoClobber(limit + i, i); + } else { + map.putAssumeCapacity(limit + i, i); + } + } + + i = 9 * limit; + while (i < 10 * limit) : (i += 1) { + try expectEqual(map.get(limit + i), i); + } + try expectEqual(map.available, 0); + try expectEqual(map.count(), limit); +} + +test "HashMap getOrPut" { + const Map = AutoHashMapUnmanaged(u32, u32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: u32 = 0; + while (i < 10) : (i += 1) { + try map.put2(i * 2, 2); + } + + i = 0; + while (i < 20) : (i += 1) { + _ = try map.getOrPutValue2(i, 1); + } + + i = 0; + var sum = i; + while (i < 20) : (i += 1) { + sum += map.get(i).?; + } + + try expectEqual(sum, 30); +} + +test "HashMap basic hash map usage" { + const Map = AutoHashMapUnmanaged(i32, i32); + const cap = 32; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + try testing.expect((try map.fetchPut2(1, 11)) == null); + try testing.expect((try map.fetchPut2(2, 22)) == null); + try testing.expect((try map.fetchPut2(3, 33)) == null); + try testing.expect((try map.fetchPut2(4, 44)) == null); + + try map.putNoClobber(alloc, 5, 55); + try testing.expect((try map.fetchPut2(5, 66)).?.value == 55); + try testing.expect((try map.fetchPut2(5, 55)).?.value == 66); + + const gop1 = try map.getOrPut2(5); + try testing.expect(gop1.found_existing == true); + try testing.expect(gop1.value_ptr.* == 55); + gop1.value_ptr.* = 77; + try testing.expect(map.getEntry(5).?.value_ptr.* == 77); + + const gop2 = try map.getOrPut2(99); + try testing.expect(gop2.found_existing == false); + gop2.value_ptr.* = 42; + try testing.expect(map.getEntry(99).?.value_ptr.* == 42); + + const gop3 = try map.getOrPutValue(alloc, 5, 5); + try testing.expect(gop3.value_ptr.* == 77); + + const gop4 = try map.getOrPutValue(alloc, 100, 41); + try testing.expect(gop4.value_ptr.* == 41); + + try testing.expect(map.contains(2)); + try testing.expect(map.getEntry(2).?.value_ptr.* == 22); + try testing.expect(map.get(2).? == 22); + + const rmv1 = map.fetchRemove(2); + try testing.expect(rmv1.?.key == 2); + try testing.expect(rmv1.?.value == 22); + try testing.expect(map.fetchRemove(2) == null); + try testing.expect(map.remove(2) == false); + try testing.expect(map.getEntry(2) == null); + try testing.expect(map.get(2) == null); + + try testing.expect(map.remove(3) == true); +} + +test "HashMap ensureUnusedCapacity" { + const Map = AutoHashMapUnmanaged(u64, u64); + const cap = 64; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + try map.ensureUnusedCapacity2(32); + try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity2(cap + 1)); +} + +test "HashMap removeByPtr" { + const Map = AutoHashMapUnmanaged(i32, u64); + const cap = 64; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + var i: i32 = undefined; + i = 0; + while (i < 10) : (i += 1) { + try map.put2(i, 0); + } + + try testing.expect(map.count() == 10); + + i = 0; + while (i < 10) : (i += 1) { + const key_ptr = map.getKeyPtr(i); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + } + + try testing.expect(map.count() == 0); +} + +test "HashMap removeByPtr 0 sized key" { + const Map = AutoHashMapUnmanaged(i32, u64); + const cap = 64; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + try map.put2(0, 0); + + try testing.expect(map.count() == 1); + + const key_ptr = map.getKeyPtr(0); + try testing.expect(key_ptr != null); + + if (key_ptr) |ptr| { + map.removeByPtr(ptr); + } + + try testing.expect(map.count() == 0); +} + +test "HashMap repeat fetchRemove" { + const Map = AutoHashMapUnmanaged(u64, void); + const cap = 64; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + map.putAssumeCapacity(0, {}); + map.putAssumeCapacity(1, {}); + map.putAssumeCapacity(2, {}); + map.putAssumeCapacity(3, {}); + + // fetchRemove() should make slots available. + var i: usize = 0; + while (i < 10) : (i += 1) { + try testing.expect(map.fetchRemove(3) != null); + map.putAssumeCapacity(3, {}); + } + + try testing.expect(map.get(0) != null); + try testing.expect(map.get(1) != null); + try testing.expect(map.get(2) != null); + try testing.expect(map.get(3) != null); +} diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 4443a92917..305b9ff396 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -54,7 +54,8 @@ test "Offset ptr structural" { const Struct = struct { x: u32, y: u32 }; const testing = std.testing; const offset: Offset(Struct) = .{ .offset = @alignOf(Struct) * 4 }; - const base_int: usize = @intFromPtr(&offset); - const actual = offset.ptr(&offset); + const base_int: usize = std.mem.alignForward(usize, @intFromPtr(&offset), @alignOf(Struct)); + const base: [*]u8 = @ptrFromInt(base_int); + const actual = offset.ptr(base); try testing.expectEqual(@as(usize, base_int + offset.offset), @intFromPtr(actual)); } From 1a3c6172895c44cc582f84eed6931ac0b4c66261 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 20:51:43 -0800 Subject: [PATCH 004/428] terminal/new: hash map remove old functions --- src/terminal/new/hash_map.zig | 822 +--------------------------------- 1 file changed, 3 insertions(+), 819 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 90ea3aa7f0..53f89f57ca 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -277,38 +277,16 @@ pub fn HashMapUnmanaged( return map; } - pub fn deinit(self: *Self, allocator: Allocator) void { - self.deallocate(allocator); - self.* = undefined; - } - pub fn capacityForSize(size: Size) Size { var new_cap: u32 = @truncate((@as(u64, size) * 100) / max_load_percentage + 1); new_cap = math.ceilPowerOfTwo(u32, new_cap) catch unreachable; return new_cap; } - pub fn ensureTotalCapacity(self: *Self, allocator: Allocator, new_size: Size) Allocator.Error!void { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call ensureTotalCapacityContext instead."); - return ensureTotalCapacityContext(self, allocator, new_size, undefined); - } - pub fn ensureTotalCapacityContext(self: *Self, allocator: Allocator, new_size: Size, ctx: Context) Allocator.Error!void { - if (new_size > self.size) - try self.growIfNeeded(allocator, new_size - self.size, ctx); - } pub fn ensureTotalCapacity2(self: *Self, new_size: Size) Allocator.Error!void { if (new_size > self.size) try self.growIfNeeded2(new_size - self.size); } - pub fn ensureUnusedCapacity(self: *Self, allocator: Allocator, additional_size: Size) Allocator.Error!void { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call ensureUnusedCapacityContext instead."); - return ensureUnusedCapacityContext(self, allocator, additional_size, undefined); - } - pub fn ensureUnusedCapacityContext(self: *Self, allocator: Allocator, additional_size: Size, ctx: Context) Allocator.Error!void { - return ensureTotalCapacityContext(self, allocator, self.count() + additional_size, ctx); - } pub fn ensureUnusedCapacity2(self: *Self, additional_size: Size) Allocator.Error!void { return ensureTotalCapacity2(self, self.count() + additional_size); } @@ -321,12 +299,6 @@ pub fn HashMapUnmanaged( } } - pub fn clearAndFree(self: *Self, allocator: Allocator) void { - self.deallocate(allocator); - self.size = 0; - self.available = 0; - } - pub fn count(self: *const Self) Size { return self.size; } @@ -386,17 +358,6 @@ pub fn HashMapUnmanaged( } /// Insert an entry in the map. Assumes it is not already present. - pub fn putNoClobber(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!void { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); - return self.putNoClobberContext(allocator, key, value, undefined); - } - pub fn putNoClobberContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!void { - assert(!self.containsContext(key, ctx)); - try self.growIfNeeded(allocator, 1, ctx); - - self.putAssumeCapacityNoClobberContext(key, value, ctx); - } pub fn putNoClobber2(self: *Self, key: K, value: V) Allocator.Error!void { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); @@ -454,23 +415,6 @@ pub fn HashMapUnmanaged( } /// Inserts a new `Entry` into the hash map, returning the previous one, if any. - pub fn fetchPut(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!?KV { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); - return self.fetchPutContext(allocator, key, value, undefined); - } - pub fn fetchPutContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!?KV { - const gop = try self.getOrPutContext(allocator, key, ctx); - var result: ?KV = null; - if (gop.found_existing) { - result = KV{ - .key = gop.key_ptr.*, - .value = gop.value_ptr.*, - }; - } - gop.value_ptr.* = value; - return result; - } pub fn fetchPut2(self: *Self, key: K, value: V) Allocator.Error!?KV { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); @@ -621,16 +565,6 @@ pub fn HashMapUnmanaged( result.value_ptr.* = value; } - pub fn put(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!void { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); - return self.putContext(allocator, key, value, undefined); - } - pub fn putContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!void { - const result = try self.getOrPutContext(allocator, key, ctx); - result.value_ptr.* = value; - } - /// Get an optional pointer to the actual key associated with adapted key, if present. pub fn getKeyPtr(self: Self, key: K) ?*K { if (@sizeOf(Context) != 0) @@ -695,18 +629,6 @@ pub fn HashMapUnmanaged( return null; } - pub fn getOrPut(self: *Self, allocator: Allocator, key: K) Allocator.Error!GetOrPutResult { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); - return self.getOrPutContext(allocator, key, undefined); - } - pub fn getOrPutContext(self: *Self, allocator: Allocator, key: K, ctx: Context) Allocator.Error!GetOrPutResult { - const gop = try self.getOrPutContextAdapted(allocator, key, ctx, ctx); - if (!gop.found_existing) { - gop.key_ptr.* = key; - } - return gop; - } pub fn getOrPut2(self: *Self, key: K) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); @@ -719,25 +641,6 @@ pub fn HashMapUnmanaged( } return gop; } - pub fn getOrPutAdapted(self: *Self, allocator: Allocator, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); - return self.getOrPutContextAdapted(allocator, key, key_ctx, undefined); - } - pub fn getOrPutContextAdapted(self: *Self, allocator: Allocator, key: anytype, key_ctx: anytype, ctx: Context) Allocator.Error!GetOrPutResult { - self.growIfNeeded(allocator, 1, ctx) catch |err| { - // If allocation fails, try to do the lookup anyway. - // If we find an existing item, we can return it. - // Otherwise return the error, we could not add another. - const index = self.getIndex(key, key_ctx) orelse return err; - return GetOrPutResult{ - .key_ptr = &self.keys()[index], - .value_ptr = &self.values()[index], - .found_existing = true, - }; - }; - return self.getOrPutAssumeCapacityAdapted(key, key_ctx); - } pub fn getOrPutAdapted2(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); @@ -837,19 +740,6 @@ pub fn HashMapUnmanaged( }; } - pub fn getOrPutValue(self: *Self, allocator: Allocator, key: K, value: V) Allocator.Error!Entry { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); - return self.getOrPutValueContext(allocator, key, value, undefined); - } - pub fn getOrPutValueContext(self: *Self, allocator: Allocator, key: K, value: V, ctx: Context) Allocator.Error!Entry { - const res = try self.getOrPutAdapted(allocator, key, ctx); - if (!res.found_existing) { - res.key_ptr.* = key; - res.value_ptr.* = value; - } - return Entry{ .key_ptr = res.key_ptr, .value_ptr = res.value_ptr }; - } pub fn getOrPutValue2(self: *Self, key: K, value: V) Allocator.Error!Entry { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); @@ -933,84 +823,10 @@ pub fn HashMapUnmanaged( return @as(Size, @truncate(max_load - self.available)); } - fn growIfNeeded(self: *Self, allocator: Allocator, new_count: Size, ctx: Context) Allocator.Error!void { - if (new_count > self.available) { - try self.grow(allocator, capacityForSize(self.load() + new_count), ctx); - } - } fn growIfNeeded2(self: *Self, new_count: Size) Allocator.Error!void { if (new_count > self.available) return error.OutOfMemory; } - pub fn clone(self: Self, allocator: Allocator) Allocator.Error!Self { - if (@sizeOf(Context) != 0) - @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call cloneContext instead."); - return self.cloneContext(allocator, @as(Context, undefined)); - } - pub fn cloneContext(self: Self, allocator: Allocator, new_ctx: anytype) Allocator.Error!HashMapUnmanaged(K, V, @TypeOf(new_ctx), max_load_percentage) { - var other = HashMapUnmanaged(K, V, @TypeOf(new_ctx), max_load_percentage){}; - if (self.size == 0) - return other; - - const new_cap = capacityForSize(self.size); - try other.allocate(allocator, new_cap); - other.initMetadatas(); - other.available = @truncate((new_cap * max_load_percentage) / 100); - - var i: Size = 0; - var metadata = self.metadata.?; - const keys_ptr = self.keys(); - const values_ptr = self.values(); - while (i < self.capacity()) : (i += 1) { - if (metadata[i].isUsed()) { - other.putAssumeCapacityNoClobberContext(keys_ptr[i], values_ptr[i], new_ctx); - if (other.size == self.size) - break; - } - } - - return other; - } - - /// Set the map to an empty state, making deinitialization a no-op, and - /// returning a copy of the original. - pub fn move(self: *Self) Self { - const result = self.*; - self.* = .{}; - return result; - } - - fn grow(self: *Self, allocator: Allocator, new_capacity: Size, ctx: Context) Allocator.Error!void { - @setCold(true); - const new_cap = @max(new_capacity, minimal_capacity); - assert(new_cap > self.capacity()); - assert(std.math.isPowerOfTwo(new_cap)); - - var map = Self{}; - defer map.deinit(allocator); - try map.allocate(allocator, new_cap); - map.initMetadatas(); - map.available = @truncate((new_cap * max_load_percentage) / 100); - - if (self.size != 0) { - const old_capacity = self.capacity(); - var i: Size = 0; - var metadata = self.metadata.?; - const keys_ptr = self.keys(); - const values_ptr = self.values(); - while (i < old_capacity) : (i += 1) { - if (metadata[i].isUsed()) { - map.putAssumeCapacityNoClobberContext(keys_ptr[i], values_ptr[i], ctx); - if (map.size == self.size) - break; - } - } - } - - self.size = 0; - std.mem.swap(Self, self, &map); - } - /// The memory layout for the underlying buffer for a given capacity. pub const Layout = struct { /// The total size of the buffer required. The buffer is expected @@ -1046,60 +862,6 @@ pub fn HashMapUnmanaged( .vals_start = vals_start, }; } - - fn allocate(self: *Self, allocator: Allocator, new_capacity: Size) Allocator.Error!void { - const layout = layoutForCapacity(new_capacity); - const slice = try allocator.alignedAlloc(u8, max_align, layout.total_size); - const ptr = @intFromPtr(slice.ptr); - - const metadata = ptr + @sizeOf(Header); - - const hdr = @as(*Header, @ptrFromInt(ptr)); - if (@sizeOf([*]V) != 0) { - hdr.values = @as([*]V, @ptrFromInt(ptr + layout.vals_start)); - } - if (@sizeOf([*]K) != 0) { - hdr.keys = @as([*]K, @ptrFromInt(ptr + layout.keys_start)); - } - hdr.capacity = new_capacity; - self.metadata = @as([*]Metadata, @ptrFromInt(metadata)); - } - - fn deallocate(self: *Self, allocator: Allocator) void { - if (self.metadata == null) return; - - const cap = self.capacity(); - const meta_size = @sizeOf(Header) + cap * @sizeOf(Metadata); - comptime assert(@alignOf(Metadata) == 1); - - const keys_start = std.mem.alignForward(usize, meta_size, key_align); - const keys_end = keys_start + cap * @sizeOf(K); - - const vals_start = std.mem.alignForward(usize, keys_end, val_align); - const vals_end = vals_start + cap * @sizeOf(V); - - const total_size = std.mem.alignForward(usize, vals_end, max_align); - - const slice = @as([*]align(max_align) u8, @ptrFromInt(@intFromPtr(self.header())))[0..total_size]; - allocator.free(slice); - - self.metadata = null; - self.available = 0; - } - - /// This function is used in the debugger pretty formatters in tools/ to fetch the - /// header type to facilitate fancy debug printing for this type. - fn dbHelper(self: *Self, hdr: *Header, entry: *Entry) void { - _ = self; - _ = hdr; - _ = entry; - } - - comptime { - if (builtin.mode == .Debug) { - _ = &dbHelper; - } - } }; } @@ -1107,584 +869,6 @@ const testing = std.testing; const expect = std.testing.expect; const expectEqual = std.testing.expectEqual; -test "std.hash_map basic usage" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - const count = 5; - var i: u32 = 0; - var total: u32 = 0; - while (i < count) : (i += 1) { - try map.put(alloc, i, i); - total += i; - } - - var sum: u32 = 0; - var it = map.iterator(); - while (it.next()) |kv| { - sum += kv.key_ptr.*; - } - try expectEqual(total, sum); - - i = 0; - sum = 0; - while (i < count) : (i += 1) { - try expectEqual(i, map.get(i).?); - sum += map.get(i).?; - } - try expectEqual(total, sum); -} - -test "std.hash_map ensureTotalCapacity" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(i32, i32) = .{}; - defer map.deinit(alloc); - - try map.ensureTotalCapacity(alloc, 20); - const initial_capacity = map.capacity(); - try testing.expect(initial_capacity >= 20); - var i: i32 = 0; - while (i < 20) : (i += 1) { - try testing.expect(map.fetchPutAssumeCapacity(i, i + 10) == null); - } - // shouldn't resize from putAssumeCapacity - try testing.expect(initial_capacity == map.capacity()); -} - -test "std.hash_map ensureUnusedCapacity with tombstones" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(i32, i32) = .{}; - defer map.deinit(alloc); - - var i: i32 = 0; - while (i < 100) : (i += 1) { - try map.ensureUnusedCapacity(alloc, 1); - map.putAssumeCapacity(i, i); - _ = map.remove(i); - } -} - -test "std.hash_map clearRetainingCapacity" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - map.clearRetainingCapacity(); - - try map.put(alloc, 1, 1); - try expectEqual(map.get(1).?, 1); - try expectEqual(map.count(), 1); - - map.clearRetainingCapacity(); - map.putAssumeCapacity(1, 1); - try expectEqual(map.get(1).?, 1); - try expectEqual(map.count(), 1); - - const cap = map.capacity(); - try expect(cap > 0); - - map.clearRetainingCapacity(); - map.clearRetainingCapacity(); - try expectEqual(map.count(), 0); - try expectEqual(map.capacity(), cap); - try expect(!map.contains(1)); -} - -test "std.hash_map grow" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - const growTo = 12456; - - var i: u32 = 0; - while (i < growTo) : (i += 1) { - try map.put(alloc, i, i); - } - try expectEqual(map.count(), growTo); - - i = 0; - var it = map.iterator(); - while (it.next()) |kv| { - try expectEqual(kv.key_ptr.*, kv.value_ptr.*); - i += 1; - } - try expectEqual(i, growTo); - - i = 0; - while (i < growTo) : (i += 1) { - try expectEqual(map.get(i).?, i); - } -} - -test "std.hash_map clone" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var a = try map.clone(alloc); - defer a.deinit(alloc); - - try expectEqual(a.count(), 0); - - try a.put(alloc, 1, 1); - try a.put(alloc, 2, 2); - try a.put(alloc, 3, 3); - - var b = try a.clone(alloc); - defer b.deinit(alloc); - - try expectEqual(b.count(), 3); - try expectEqual(b.get(1).?, 1); - try expectEqual(b.get(2).?, 2); - try expectEqual(b.get(3).?, 3); - - var original: AutoHashMapUnmanaged(i32, i32) = .{}; - defer original.deinit(alloc); - - var i: u8 = 0; - while (i < 10) : (i += 1) { - try original.putNoClobber(alloc, i, i * 10); - } - - var copy = try original.clone(alloc); - defer copy.deinit(alloc); - - i = 0; - while (i < 10) : (i += 1) { - try testing.expect(copy.get(i).? == i * 10); - } -} - -test "std.hash_map ensureTotalCapacity with existing elements" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - try map.put(alloc, 0, 0); - try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), @TypeOf(map).minimal_capacity); - - try map.ensureTotalCapacity(alloc, 65); - try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), 128); -} - -test "std.hash_map ensureTotalCapacity satisfies max load factor" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - try map.ensureTotalCapacity(alloc, 127); - try expectEqual(map.capacity(), 256); -} - -test "std.hash_map remove" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var i: u32 = 0; - while (i < 16) : (i += 1) { - try map.put(alloc, i, i); - } - - i = 0; - while (i < 16) : (i += 1) { - if (i % 3 == 0) { - _ = map.remove(i); - } - } - try expectEqual(map.count(), 10); - var it = map.iterator(); - while (it.next()) |kv| { - try expectEqual(kv.key_ptr.*, kv.value_ptr.*); - try expect(kv.key_ptr.* % 3 != 0); - } - - i = 0; - while (i < 16) : (i += 1) { - if (i % 3 == 0) { - try expect(!map.contains(i)); - } else { - try expectEqual(map.get(i).?, i); - } - } -} - -test "std.hash_map reverse removes" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var i: u32 = 0; - while (i < 16) : (i += 1) { - try map.putNoClobber(alloc, i, i); - } - - i = 16; - while (i > 0) : (i -= 1) { - _ = map.remove(i - 1); - try expect(!map.contains(i - 1)); - var j: u32 = 0; - while (j < i - 1) : (j += 1) { - try expectEqual(map.get(j).?, j); - } - } - - try expectEqual(map.count(), 0); -} - -test "std.hash_map multiple removes on same metadata" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var i: u32 = 0; - while (i < 16) : (i += 1) { - try map.put(alloc, i, i); - } - - _ = map.remove(7); - _ = map.remove(15); - _ = map.remove(14); - _ = map.remove(13); - try expect(!map.contains(7)); - try expect(!map.contains(15)); - try expect(!map.contains(14)); - try expect(!map.contains(13)); - - i = 0; - while (i < 13) : (i += 1) { - if (i == 7) { - try expect(!map.contains(i)); - } else { - try expectEqual(map.get(i).?, i); - } - } - - try map.put(alloc, 15, 15); - try map.put(alloc, 13, 13); - try map.put(alloc, 14, 14); - try map.put(alloc, 7, 7); - i = 0; - while (i < 16) : (i += 1) { - try expectEqual(map.get(i).?, i); - } -} - -test "std.hash_map put and remove loop in random order" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var keys = std.ArrayList(u32).init(std.testing.allocator); - defer keys.deinit(); - - const size = 32; - const iterations = 100; - - var i: u32 = 0; - while (i < size) : (i += 1) { - try keys.append(i); - } - var prng = std.Random.DefaultPrng.init(0); - const random = prng.random(); - - while (i < iterations) : (i += 1) { - random.shuffle(u32, keys.items); - - for (keys.items) |key| { - try map.put(alloc, key, key); - } - try expectEqual(map.count(), size); - - for (keys.items) |key| { - _ = map.remove(key); - } - try expectEqual(map.count(), 0); - } -} - -test "std.hash_map remove one million elements in random order" { - const alloc = testing.allocator; - const n = 1000 * 1000; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var keys = std.ArrayList(u32).init(std.heap.page_allocator); - defer keys.deinit(); - - var i: u32 = 0; - while (i < n) : (i += 1) { - keys.append(i) catch unreachable; - } - - var prng = std.Random.DefaultPrng.init(0); - const random = prng.random(); - random.shuffle(u32, keys.items); - - for (keys.items) |key| { - map.put(alloc, key, key) catch unreachable; - } - - random.shuffle(u32, keys.items); - i = 0; - while (i < n) : (i += 1) { - const key = keys.items[i]; - _ = map.remove(key); - } -} - -test "std.hash_map put" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var i: u32 = 0; - while (i < 16) : (i += 1) { - try map.put(alloc, i, i); - } - - i = 0; - while (i < 16) : (i += 1) { - try expectEqual(map.get(i).?, i); - } - - i = 0; - while (i < 16) : (i += 1) { - try map.put(alloc, i, i * 16 + 1); - } - - i = 0; - while (i < 16) : (i += 1) { - try expectEqual(map.get(i).?, i * 16 + 1); - } -} - -test "std.hash_map putAssumeCapacity" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - try map.ensureTotalCapacity(alloc, 20); - var i: u32 = 0; - while (i < 20) : (i += 1) { - map.putAssumeCapacityNoClobber(i, i); - } - - i = 0; - var sum = i; - while (i < 20) : (i += 1) { - sum += map.getPtr(i).?.*; - } - try expectEqual(sum, 190); - - i = 0; - while (i < 20) : (i += 1) { - map.putAssumeCapacity(i, 1); - } - - i = 0; - sum = i; - while (i < 20) : (i += 1) { - sum += map.get(i).?; - } - try expectEqual(sum, 20); -} - -test "std.hash_map repeat putAssumeCapacity/remove" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - try map.ensureTotalCapacity(alloc, 20); - const limit = map.available; - - var i: u32 = 0; - while (i < limit) : (i += 1) { - map.putAssumeCapacityNoClobber(i, i); - } - - // Repeatedly delete/insert an entry without resizing the map. - // Put to different keys so entries don't land in the just-freed slot. - i = 0; - while (i < 10 * limit) : (i += 1) { - try testing.expect(map.remove(i)); - if (i % 2 == 0) { - map.putAssumeCapacityNoClobber(limit + i, i); - } else { - map.putAssumeCapacity(limit + i, i); - } - } - - i = 9 * limit; - while (i < 10 * limit) : (i += 1) { - try expectEqual(map.get(limit + i), i); - } - try expectEqual(map.available, 0); - try expectEqual(map.count(), limit); -} - -test "std.hash_map getOrPut" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u32, u32) = .{}; - defer map.deinit(alloc); - - var i: u32 = 0; - while (i < 10) : (i += 1) { - try map.put(alloc, i * 2, 2); - } - - i = 0; - while (i < 20) : (i += 1) { - _ = try map.getOrPutValue(alloc, i, 1); - } - - i = 0; - var sum = i; - while (i < 20) : (i += 1) { - sum += map.get(i).?; - } - - try expectEqual(sum, 30); -} - -test "std.hash_map basic hash map usage" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(i32, i32) = .{}; - defer map.deinit(alloc); - - try testing.expect((try map.fetchPut(alloc, 1, 11)) == null); - try testing.expect((try map.fetchPut(alloc, 2, 22)) == null); - try testing.expect((try map.fetchPut(alloc, 3, 33)) == null); - try testing.expect((try map.fetchPut(alloc, 4, 44)) == null); - - try map.putNoClobber(alloc, 5, 55); - try testing.expect((try map.fetchPut(alloc, 5, 66)).?.value == 55); - try testing.expect((try map.fetchPut(alloc, 5, 55)).?.value == 66); - - const gop1 = try map.getOrPut(alloc, 5); - try testing.expect(gop1.found_existing == true); - try testing.expect(gop1.value_ptr.* == 55); - gop1.value_ptr.* = 77; - try testing.expect(map.getEntry(5).?.value_ptr.* == 77); - - const gop2 = try map.getOrPut(alloc, 99); - try testing.expect(gop2.found_existing == false); - gop2.value_ptr.* = 42; - try testing.expect(map.getEntry(99).?.value_ptr.* == 42); - - const gop3 = try map.getOrPutValue(alloc, 5, 5); - try testing.expect(gop3.value_ptr.* == 77); - - const gop4 = try map.getOrPutValue(alloc, 100, 41); - try testing.expect(gop4.value_ptr.* == 41); - - try testing.expect(map.contains(2)); - try testing.expect(map.getEntry(2).?.value_ptr.* == 22); - try testing.expect(map.get(2).? == 22); - - const rmv1 = map.fetchRemove(2); - try testing.expect(rmv1.?.key == 2); - try testing.expect(rmv1.?.value == 22); - try testing.expect(map.fetchRemove(2) == null); - try testing.expect(map.remove(2) == false); - try testing.expect(map.getEntry(2) == null); - try testing.expect(map.get(2) == null); - - try testing.expect(map.remove(3) == true); -} - -test "std.hash_map ensureUnusedCapacity" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u64, u64) = .{}; - defer map.deinit(alloc); - - try map.ensureUnusedCapacity(alloc, 32); - const capacity = map.capacity(); - try map.ensureUnusedCapacity(alloc, 32); - - // Repeated ensureUnusedCapacity() calls with no insertions between - // should not change the capacity. - try testing.expectEqual(capacity, map.capacity()); -} - -test "std.hash_map removeByPtr" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(i32, u64) = .{}; - defer map.deinit(alloc); - - var i: i32 = undefined; - - i = 0; - while (i < 10) : (i += 1) { - try map.put(alloc, i, 0); - } - - try testing.expect(map.count() == 10); - - i = 0; - while (i < 10) : (i += 1) { - const key_ptr = map.getKeyPtr(i); - try testing.expect(key_ptr != null); - - if (key_ptr) |ptr| { - map.removeByPtr(ptr); - } - } - - try testing.expect(map.count() == 0); -} - -test "std.hash_map removeByPtr 0 sized key" { - const alloc = testing.allocator; - var map: AutoHashMapUnmanaged(u0, u64) = .{}; - defer map.deinit(alloc); - - try map.put(alloc, 0, 0); - - try testing.expect(map.count() == 1); - - const key_ptr = map.getKeyPtr(0); - try testing.expect(key_ptr != null); - - if (key_ptr) |ptr| { - map.removeByPtr(ptr); - } - - try testing.expect(map.count() == 0); -} - -test "std.hash_map repeat fetchRemove" { - const alloc = testing.allocator; - var map = AutoHashMapUnmanaged(u64, void){}; - defer map.deinit(alloc); - - try map.ensureTotalCapacity(alloc, 4); - - map.putAssumeCapacity(0, {}); - map.putAssumeCapacity(1, {}); - map.putAssumeCapacity(2, {}); - map.putAssumeCapacity(3, {}); - - // fetchRemove() should make slots available. - var i: usize = 0; - while (i < 10) : (i += 1) { - try testing.expect(map.fetchRemove(3) != null); - map.putAssumeCapacity(3, {}); - } - - try testing.expect(map.get(0) != null); - try testing.expect(map.get(1) != null); - try testing.expect(map.get(2) != null); - try testing.expect(map.get(3) != null); -} - -//------------------------------------------------------------------- -// New tests - test "HashMap basic usage" { const Map = AutoHashMapUnmanaged(u32, u32); @@ -2122,7 +1306,7 @@ test "HashMap basic hash map usage" { try testing.expect((try map.fetchPut2(3, 33)) == null); try testing.expect((try map.fetchPut2(4, 44)) == null); - try map.putNoClobber(alloc, 5, 55); + try map.putNoClobber2(5, 55); try testing.expect((try map.fetchPut2(5, 66)).?.value == 55); try testing.expect((try map.fetchPut2(5, 55)).?.value == 66); @@ -2137,10 +1321,10 @@ test "HashMap basic hash map usage" { gop2.value_ptr.* = 42; try testing.expect(map.getEntry(99).?.value_ptr.* == 42); - const gop3 = try map.getOrPutValue(alloc, 5, 5); + const gop3 = try map.getOrPutValue2(5, 5); try testing.expect(gop3.value_ptr.* == 77); - const gop4 = try map.getOrPutValue(alloc, 100, 41); + const gop4 = try map.getOrPutValue2(100, 41); try testing.expect(gop4.value_ptr.* == 41); try testing.expect(map.contains(2)); From 6b2ec38a054c47299e31ffa63cf89ed05aaf211d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 21:06:15 -0800 Subject: [PATCH 005/428] terminal/new: remove more functions --- src/terminal/new/hash_map.zig | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 53f89f57ca..b9c214dae7 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -39,6 +39,8 @@ const mem = std.mem; const Allocator = mem.Allocator; const Wyhash = std.hash.Wyhash; +const Offset = @import("size.zig").Offset; + pub fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { return HashMapUnmanaged(K, V, AutoContext(K), default_max_load_percentage); } @@ -247,10 +249,6 @@ pub fn HashMapUnmanaged( found_existing: bool, }; - fn isUnderMaxLoadPercentage(size: Size, cap: Size) bool { - return size * 100 < max_load_percentage * cap; - } - /// Initialize a hash map with a given capacity and a buffer. The /// buffer must fit within the size defined by `layoutForCapacity`. pub fn init(new_capacity: Size, buf: []u8) Self { @@ -815,14 +813,6 @@ pub fn HashMapUnmanaged( @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); } - // This counts the number of occupied slots (not counting tombstones), which is - // what has to stay under the max_load_percentage of capacity. - fn load(self: *const Self) Size { - const max_load = (self.capacity() * max_load_percentage) / 100; - assert(max_load >= self.available); - return @as(Size, @truncate(max_load - self.available)); - } - fn growIfNeeded2(self: *Self, new_count: Size) Allocator.Error!void { if (new_count > self.available) return error.OutOfMemory; } From 3200d4cb4da3dae3d797320a6b550e1bcbc7ba82 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 21:40:14 -0800 Subject: [PATCH 006/428] terminal/new: slapped together offset map --- src/terminal/new/hash_map.zig | 121 ++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 42 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index b9c214dae7..d8ead7c839 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -45,6 +45,10 @@ pub fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { return HashMapUnmanaged(K, V, AutoContext(K), default_max_load_percentage); } +pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { + return OffsetHashMap(K, V, AutoContext(K), default_max_load_percentage); +} + pub fn AutoContext(comptime K: type) type { return struct { pub const hash = std.hash_map.getAutoHashFn(K, @This()); @@ -54,6 +58,40 @@ pub fn AutoContext(comptime K: type) type { pub const default_max_load_percentage = 80; +pub fn OffsetHashMap( + comptime K: type, + comptime V: type, + comptime Context: type, + comptime max_load_percentage: u64, +) type { + return struct { + const Self = @This(); + + pub const Unmanaged = HashMapUnmanaged(K, V, Context, max_load_percentage); + + metadata: Offset(Unmanaged.Metadata) = .{}, + size: Unmanaged.Size = 0, + available: Unmanaged.Size = 0, + + pub fn init(cap: Unmanaged.Size, buf: []u8) Self { + const m = Unmanaged.init(cap, buf); + return .{ + .metadata = .{ .offset = @intCast(@intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr)) }, + .size = m.size, + .available = m.available, + }; + } + + pub fn map(self: Self, base: anytype) Unmanaged { + return .{ + .metadata = self.metadata.ptr(base), + .size = self.size, + .available = self.available, + }; + } + }; +} + /// A HashMap based on open addressing and linear probing. /// A lookup or modification typically incurs only 2 cache misses. /// No order is guaranteed and any modification invalidates live iterators. @@ -125,8 +163,8 @@ pub fn HashMapUnmanaged( }; const Header = struct { - values: [*]V, - keys: [*]K, + values: Offset(V), + keys: Offset(K), capacity: Size, }; @@ -260,15 +298,13 @@ pub fn HashMapUnmanaged( // Get all our main pointers const metadata_ptr: [*]Metadata = @ptrFromInt(base + @sizeOf(Header)); - const keys_ptr: [*]K = @ptrFromInt(base + layout.keys_start); - const values_ptr: [*]V = @ptrFromInt(base + layout.vals_start); // Build our map var map: Self = .{ .metadata = metadata_ptr }; const hdr = map.header(); hdr.capacity = new_capacity; - if (@sizeOf([*]K) != 0) hdr.keys = keys_ptr; - if (@sizeOf([*]V) != 0) hdr.values = values_ptr; + if (@sizeOf([*]K) != 0) hdr.keys = .{ .offset = @intCast(layout.keys_start) }; + if (@sizeOf([*]V) != 0) hdr.values = .{ .offset = @intCast(layout.vals_start) }; map.initMetadatas(); map.available = @truncate((new_capacity * max_load_percentage) / 100); @@ -306,11 +342,11 @@ pub fn HashMapUnmanaged( } fn keys(self: *const Self) [*]K { - return self.header().keys; + return self.header().keys.ptr(self.metadata.?); } fn values(self: *const Self) [*]V { - return self.header().values; + return self.header().values.ptr(self.metadata.?); } pub fn capacity(self: *const Self) Size { @@ -1120,40 +1156,6 @@ test "HashMap put and remove loop in random order" { } } -test "HashMap remove one million elements in random order" { - const Map = AutoHashMapUnmanaged(u32, u32); - const n = 1000 * 1000; - const cap = Map.capacityForSize(n); - - const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); - defer alloc.free(buf); - var map = Map.init(cap, buf); - - var keys = std.ArrayList(u32).init(alloc); - defer keys.deinit(); - - var i: u32 = 0; - while (i < n) : (i += 1) { - keys.append(i) catch unreachable; - } - - var prng = std.Random.DefaultPrng.init(0); - const random = prng.random(); - random.shuffle(u32, keys.items); - - for (keys.items) |key| { - map.put2(key, key) catch unreachable; - } - - random.shuffle(u32, keys.items); - i = 0; - while (i < n) : (i += 1) { - const key = keys.items[i]; - _ = map.remove(key); - } -} - test "HashMap put" { const Map = AutoHashMapUnmanaged(u32, u32); const cap = 32; @@ -1424,3 +1426,38 @@ test "HashMap repeat fetchRemove" { try testing.expect(map.get(2) != null); try testing.expect(map.get(3) != null); } + +test "OffsetHashMap basic usage" { + const OffsetMap = AutoOffsetHashMap(u32, u32); + + const alloc = testing.allocator; + const cap = 16; + const buf = try alloc.alloc(u8, OffsetMap.Unmanaged.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + + var offset_map = OffsetMap.init(cap, buf); + var map = offset_map.map(buf.ptr); + + const count = 5; + var i: u32 = 0; + var total: u32 = 0; + while (i < count) : (i += 1) { + try map.put2(i, i); + total += i; + } + + var sum: u32 = 0; + var it = map.iterator(); + while (it.next()) |kv| { + sum += kv.key_ptr.*; + } + try expectEqual(total, sum); + + i = 0; + sum = 0; + while (i < count) : (i += 1) { + try expectEqual(i, map.get(i).?); + sum += map.get(i).?; + } + try expectEqual(total, sum); +} From 210be9cd0caf9a9c43f36c5aac5ceb2721b5d8d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 21:48:19 -0800 Subject: [PATCH 007/428] terminal/new: hash map has no load factor --- src/terminal/new/hash_map.zig | 160 ++++++++++++++++++---------------- 1 file changed, 83 insertions(+), 77 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index d8ead7c839..146788625c 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -42,11 +42,11 @@ const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; pub fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { - return HashMapUnmanaged(K, V, AutoContext(K), default_max_load_percentage); + return HashMapUnmanaged(K, V, AutoContext(K)); } pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { - return OffsetHashMap(K, V, AutoContext(K), default_max_load_percentage); + return OffsetHashMap(K, V, AutoContext(K)); } pub fn AutoContext(comptime K: type) type { @@ -56,18 +56,15 @@ pub fn AutoContext(comptime K: type) type { }; } -pub const default_max_load_percentage = 80; - pub fn OffsetHashMap( comptime K: type, comptime V: type, comptime Context: type, - comptime max_load_percentage: u64, ) type { return struct { const Self = @This(); - pub const Unmanaged = HashMapUnmanaged(K, V, Context, max_load_percentage); + pub const Unmanaged = HashMapUnmanaged(K, V, Context); metadata: Offset(Unmanaged.Metadata) = .{}, size: Unmanaged.Size = 0, @@ -105,11 +102,7 @@ pub fn HashMapUnmanaged( comptime K: type, comptime V: type, comptime Context: type, - comptime max_load_percentage: u64, ) type { - if (max_load_percentage <= 0 or max_load_percentage >= 100) - @compileError("max_load_percentage must be between 0 and 100."); - return struct { const Self = @This(); @@ -306,30 +299,28 @@ pub fn HashMapUnmanaged( if (@sizeOf([*]K) != 0) hdr.keys = .{ .offset = @intCast(layout.keys_start) }; if (@sizeOf([*]V) != 0) hdr.values = .{ .offset = @intCast(layout.vals_start) }; map.initMetadatas(); - map.available = @truncate((new_capacity * max_load_percentage) / 100); + map.available = new_capacity; return map; } pub fn capacityForSize(size: Size) Size { - var new_cap: u32 = @truncate((@as(u64, size) * 100) / max_load_percentage + 1); - new_cap = math.ceilPowerOfTwo(u32, new_cap) catch unreachable; - return new_cap; + return math.ceilPowerOfTwo(u32, size + 1) catch unreachable; } - pub fn ensureTotalCapacity2(self: *Self, new_size: Size) Allocator.Error!void { - if (new_size > self.size) try self.growIfNeeded2(new_size - self.size); + pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { + if (new_size > self.size) try self.growIfNeeded(new_size - self.size); } - pub fn ensureUnusedCapacity2(self: *Self, additional_size: Size) Allocator.Error!void { - return ensureTotalCapacity2(self, self.count() + additional_size); + pub fn ensureUnusedCapacity(self: *Self, additional_size: Size) Allocator.Error!void { + return ensureTotalCapacity(self, self.count() + additional_size); } pub fn clearRetainingCapacity(self: *Self) void { if (self.metadata) |_| { self.initMetadatas(); self.size = 0; - self.available = @as(u32, @truncate((self.capacity() * max_load_percentage) / 100)); + self.available = self.capacity(); } } @@ -392,14 +383,14 @@ pub fn HashMapUnmanaged( } /// Insert an entry in the map. Assumes it is not already present. - pub fn putNoClobber2(self: *Self, key: K, value: V) Allocator.Error!void { + pub fn putNoClobber(self: *Self, key: K, value: V) Allocator.Error!void { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putNoClobberContext instead."); - return self.putNoClobberContext2(key, value, undefined); + return self.putNoClobberContext(key, value, undefined); } - pub fn putNoClobberContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + pub fn putNoClobberContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { assert(!self.containsContext(key, ctx)); - try self.growIfNeeded2(1); + try self.growIfNeeded(1); self.putAssumeCapacityNoClobberContext(key, value, ctx); } @@ -449,13 +440,13 @@ pub fn HashMapUnmanaged( } /// Inserts a new `Entry` into the hash map, returning the previous one, if any. - pub fn fetchPut2(self: *Self, key: K, value: V) Allocator.Error!?KV { + pub fn fetchPut(self: *Self, key: K, value: V) Allocator.Error!?KV { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchPutContext instead."); - return self.fetchPutContext2(key, value, undefined); + return self.fetchPutContext(key, value, undefined); } - pub fn fetchPutContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!?KV { - const gop = try self.getOrPutContext2(key, ctx); + pub fn fetchPutContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!?KV { + const gop = try self.getOrPutContext(key, ctx); var result: ?KV = null; if (gop.found_existing) { result = KV{ @@ -589,13 +580,13 @@ pub fn HashMapUnmanaged( } /// Insert an entry if the associated key is not already present, otherwise update preexisting value. - pub fn put2(self: *Self, key: K, value: V) Allocator.Error!void { + pub fn put(self: *Self, key: K, value: V) Allocator.Error!void { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call putContext instead."); - return self.putContext2(key, value, undefined); + return self.putContext(key, value, undefined); } - pub fn putContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { - const result = try self.getOrPutContext2(key, ctx); + pub fn putContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { + const result = try self.getOrPutContext(key, ctx); result.value_ptr.* = value; } @@ -663,25 +654,25 @@ pub fn HashMapUnmanaged( return null; } - pub fn getOrPut2(self: *Self, key: K) Allocator.Error!GetOrPutResult { + pub fn getOrPut(self: *Self, key: K) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContext instead."); - return self.getOrPutContext2(key, undefined); + return self.getOrPutContext(key, undefined); } - pub fn getOrPutContext2(self: *Self, key: K, ctx: Context) Allocator.Error!GetOrPutResult { - const gop = try self.getOrPutContextAdapted2(key, ctx); + pub fn getOrPutContext(self: *Self, key: K, ctx: Context) Allocator.Error!GetOrPutResult { + const gop = try self.getOrPutContextAdapted(key, ctx); if (!gop.found_existing) { gop.key_ptr.* = key; } return gop; } - pub fn getOrPutAdapted2(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + pub fn getOrPutAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutContextAdapted instead."); - return self.getOrPutContextAdapted2(key, key_ctx); + return self.getOrPutContextAdapted(key, key_ctx); } - pub fn getOrPutContextAdapted2(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { - self.growIfNeeded2(1) catch |err| { + pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { + self.growIfNeeded(1) catch |err| { // If allocation fails, try to do the lookup anyway. // If we find an existing item, we can return it. // Otherwise return the error, we could not add another. @@ -774,13 +765,13 @@ pub fn HashMapUnmanaged( }; } - pub fn getOrPutValue2(self: *Self, key: K, value: V) Allocator.Error!Entry { + pub fn getOrPutValue(self: *Self, key: K, value: V) Allocator.Error!Entry { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call getOrPutValueContext instead."); - return self.getOrPutValueContext2(key, value, undefined); + return self.getOrPutValueContext(key, value, undefined); } - pub fn getOrPutValueContext2(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!Entry { - const res = try self.getOrPutAdapted2(key, ctx); + pub fn getOrPutValueContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!Entry { + const res = try self.getOrPutAdapted(key, ctx); if (!res.found_existing) { res.key_ptr.* = key; res.value_ptr.* = value; @@ -849,7 +840,7 @@ pub fn HashMapUnmanaged( @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); } - fn growIfNeeded2(self: *Self, new_count: Size) Allocator.Error!void { + fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { if (new_count > self.available) return error.OutOfMemory; } @@ -909,7 +900,7 @@ test "HashMap basic usage" { var i: u32 = 0; var total: u32 = 0; while (i < count) : (i += 1) { - try map.put2(i, i); + try map.put(i, i); total += i; } @@ -959,7 +950,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { var i: i32 = 0; while (i < 100) : (i += 1) { - try map.ensureUnusedCapacity2(1); + try map.ensureUnusedCapacity(1); map.putAssumeCapacity(i, i); _ = map.remove(i); } @@ -976,7 +967,7 @@ test "HashMap clearRetainingCapacity" { map.clearRetainingCapacity(); - try map.put2(1, 1); + try map.put(1, 1); try expectEqual(map.get(1).?, 1); try expectEqual(map.count(), 1); @@ -1004,11 +995,11 @@ test "HashMap ensureTotalCapacity with existing elements" { defer alloc.free(buf); var map = Map.init(cap, buf); - try map.put2(0, 0); + try map.put(0, 0); try expectEqual(map.count(), 1); try expectEqual(map.capacity(), Map.minimal_capacity); - try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity2(65)); + try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity(65)); try expectEqual(map.count(), 1); try expectEqual(map.capacity(), Map.minimal_capacity); } @@ -1024,7 +1015,7 @@ test "HashMap remove" { var i: u32 = 0; while (i < 16) : (i += 1) { - try map.put2(i, i); + try map.put(i, i); } i = 0; @@ -1061,7 +1052,7 @@ test "HashMap reverse removes" { var i: u32 = 0; while (i < 16) : (i += 1) { - try map.putNoClobber2(i, i); + try map.putNoClobber(i, i); } i = 16; @@ -1088,7 +1079,7 @@ test "HashMap multiple removes on same metadata" { var i: u32 = 0; while (i < 16) : (i += 1) { - try map.put2(i, i); + try map.put(i, i); } _ = map.remove(7); @@ -1109,10 +1100,10 @@ test "HashMap multiple removes on same metadata" { } } - try map.put2(15, 15); - try map.put2(13, 13); - try map.put2(14, 14); - try map.put2(7, 7); + try map.put(15, 15); + try map.put(13, 13); + try map.put(14, 14); + try map.put(7, 7); i = 0; while (i < 16) : (i += 1) { try expectEqual(map.get(i).?, i); @@ -1145,7 +1136,7 @@ test "HashMap put and remove loop in random order" { random.shuffle(u32, keys.items); for (keys.items) |key| { - try map.put2(key, key); + try map.put(key, key); } try expectEqual(map.count(), size); @@ -1167,7 +1158,7 @@ test "HashMap put" { var i: u32 = 0; while (i < 16) : (i += 1) { - try map.put2(i, i); + try map.put(i, i); } i = 0; @@ -1177,7 +1168,7 @@ test "HashMap put" { i = 0; while (i < 16) : (i += 1) { - try map.put2(i, i * 16 + 1); + try map.put(i, i * 16 + 1); } i = 0; @@ -1186,6 +1177,21 @@ test "HashMap put" { } } +test "HashMap put full load" { + const Map = AutoHashMapUnmanaged(usize, usize); + const cap = 16; + + const alloc = testing.allocator; + const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + var map = Map.init(cap, buf); + + for (0..cap) |i| try map.put(i, i); + for (0..cap) |i| try expectEqual(map.get(i).?, i); + + try testing.expectError(error.OutOfMemory, map.put(cap, cap)); +} + test "HashMap putAssumeCapacity" { const Map = AutoHashMapUnmanaged(u32, u32); const cap = 32; @@ -1267,12 +1273,12 @@ test "HashMap getOrPut" { var i: u32 = 0; while (i < 10) : (i += 1) { - try map.put2(i * 2, 2); + try map.put(i * 2, 2); } i = 0; while (i < 20) : (i += 1) { - _ = try map.getOrPutValue2(i, 1); + _ = try map.getOrPutValue(i, 1); } i = 0; @@ -1293,30 +1299,30 @@ test "HashMap basic hash map usage" { defer alloc.free(buf); var map = Map.init(cap, buf); - try testing.expect((try map.fetchPut2(1, 11)) == null); - try testing.expect((try map.fetchPut2(2, 22)) == null); - try testing.expect((try map.fetchPut2(3, 33)) == null); - try testing.expect((try map.fetchPut2(4, 44)) == null); + try testing.expect((try map.fetchPut(1, 11)) == null); + try testing.expect((try map.fetchPut(2, 22)) == null); + try testing.expect((try map.fetchPut(3, 33)) == null); + try testing.expect((try map.fetchPut(4, 44)) == null); - try map.putNoClobber2(5, 55); - try testing.expect((try map.fetchPut2(5, 66)).?.value == 55); - try testing.expect((try map.fetchPut2(5, 55)).?.value == 66); + try map.putNoClobber(5, 55); + try testing.expect((try map.fetchPut(5, 66)).?.value == 55); + try testing.expect((try map.fetchPut(5, 55)).?.value == 66); - const gop1 = try map.getOrPut2(5); + const gop1 = try map.getOrPut(5); try testing.expect(gop1.found_existing == true); try testing.expect(gop1.value_ptr.* == 55); gop1.value_ptr.* = 77; try testing.expect(map.getEntry(5).?.value_ptr.* == 77); - const gop2 = try map.getOrPut2(99); + const gop2 = try map.getOrPut(99); try testing.expect(gop2.found_existing == false); gop2.value_ptr.* = 42; try testing.expect(map.getEntry(99).?.value_ptr.* == 42); - const gop3 = try map.getOrPutValue2(5, 5); + const gop3 = try map.getOrPutValue(5, 5); try testing.expect(gop3.value_ptr.* == 77); - const gop4 = try map.getOrPutValue2(100, 41); + const gop4 = try map.getOrPutValue(100, 41); try testing.expect(gop4.value_ptr.* == 41); try testing.expect(map.contains(2)); @@ -1343,8 +1349,8 @@ test "HashMap ensureUnusedCapacity" { defer alloc.free(buf); var map = Map.init(cap, buf); - try map.ensureUnusedCapacity2(32); - try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity2(cap + 1)); + try map.ensureUnusedCapacity(32); + try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); } test "HashMap removeByPtr" { @@ -1359,7 +1365,7 @@ test "HashMap removeByPtr" { var i: i32 = undefined; i = 0; while (i < 10) : (i += 1) { - try map.put2(i, 0); + try map.put(i, 0); } try testing.expect(map.count() == 10); @@ -1386,7 +1392,7 @@ test "HashMap removeByPtr 0 sized key" { defer alloc.free(buf); var map = Map.init(cap, buf); - try map.put2(0, 0); + try map.put(0, 0); try testing.expect(map.count() == 1); @@ -1442,7 +1448,7 @@ test "OffsetHashMap basic usage" { var i: u32 = 0; var total: u32 = 0; while (i < count) : (i += 1) { - try map.put2(i, i); + try map.put(i, i); total += i; } From ba749e85ef526f324dcd065e6ae41adeae587d73 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 21:51:39 -0800 Subject: [PATCH 008/428] terminal/new: hash map doesn't need available --- src/terminal/new/hash_map.zig | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 146788625c..c96a23ea83 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -68,14 +68,12 @@ pub fn OffsetHashMap( metadata: Offset(Unmanaged.Metadata) = .{}, size: Unmanaged.Size = 0, - available: Unmanaged.Size = 0, pub fn init(cap: Unmanaged.Size, buf: []u8) Self { const m = Unmanaged.init(cap, buf); return .{ .metadata = .{ .offset = @intCast(@intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr)) }, .size = m.size, - .available = m.available, }; } @@ -83,7 +81,6 @@ pub fn OffsetHashMap( return .{ .metadata = self.metadata.ptr(base), .size = self.size, - .available = self.available, }; } }; @@ -128,12 +125,6 @@ pub fn HashMapUnmanaged( /// Current number of elements in the hashmap. size: Size = 0, - // Having a countdown to grow reduces the number of instructions to - // execute when determining if the hashmap has enough capacity already. - /// Number of available slots before a grow is needed to satisfy the - /// `max_load_percentage`. - available: Size = 0, - // This is purely empirical and not a /very smart magic constant™/. /// Capacity of the first grow when bootstrapping the hashmap. const minimal_capacity = 8; @@ -299,7 +290,6 @@ pub fn HashMapUnmanaged( if (@sizeOf([*]K) != 0) hdr.keys = .{ .offset = @intCast(layout.keys_start) }; if (@sizeOf([*]V) != 0) hdr.values = .{ .offset = @intCast(layout.vals_start) }; map.initMetadatas(); - map.available = new_capacity; return map; } @@ -320,7 +310,6 @@ pub fn HashMapUnmanaged( if (self.metadata) |_| { self.initMetadatas(); self.size = 0; - self.available = self.capacity(); } } @@ -428,9 +417,6 @@ pub fn HashMapUnmanaged( metadata = self.metadata.? + idx; } - assert(self.available > 0); - self.available -= 1; - const fingerprint = Metadata.takeFingerprint(hash); metadata[0].fill(fingerprint); self.keys()[idx] = key; @@ -500,7 +486,6 @@ pub fn HashMapUnmanaged( old_key.* = undefined; old_val.* = undefined; self.size -= 1; - self.available += 1; return result; } @@ -748,8 +733,6 @@ pub fn HashMapUnmanaged( idx = first_tombstone_idx; metadata = self.metadata.? + idx; } - // We're using a slot previously free or a tombstone. - self.available -= 1; metadata[0].fill(fingerprint); const new_key = &self.keys()[idx]; @@ -797,7 +780,6 @@ pub fn HashMapUnmanaged( self.keys()[idx] = undefined; self.values()[idx] = undefined; self.size -= 1; - self.available += 1; } /// If there is an `Entry` with a matching key, it is deleted from @@ -841,7 +823,8 @@ pub fn HashMapUnmanaged( } fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { - if (new_count > self.available) return error.OutOfMemory; + const available = self.capacity() - self.size; + if (new_count > available) return error.OutOfMemory; } /// The memory layout for the underlying buffer for a given capacity. @@ -1235,7 +1218,7 @@ test "HashMap repeat putAssumeCapacity/remove" { defer alloc.free(buf); var map = Map.init(cap, buf); - const limit = map.available; + const limit = cap; var i: u32 = 0; while (i < limit) : (i += 1) { @@ -1258,7 +1241,6 @@ test "HashMap repeat putAssumeCapacity/remove" { while (i < 10 * limit) : (i += 1) { try expectEqual(map.get(limit + i), i); } - try expectEqual(map.available, 0); try expectEqual(map.count(), limit); } From 27d0ed05ca5b3387fbf841c7bdd78ceb354cf0d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Feb 2024 22:06:24 -0800 Subject: [PATCH 009/428] terminal/new: comment, remove some pubs --- src/terminal/new/hash_map.zig | 56 +++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index c96a23ea83..0aba74806e 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -41,21 +41,24 @@ const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; -pub fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { - return HashMapUnmanaged(K, V, AutoContext(K)); -} - pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { return OffsetHashMap(K, V, AutoContext(K)); } -pub fn AutoContext(comptime K: type) type { +fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { + return HashMapUnmanaged(K, V, AutoContext(K)); +} + +fn AutoContext(comptime K: type) type { return struct { pub const hash = std.hash_map.getAutoHashFn(K, @This()); pub const eql = std.hash_map.getAutoEqlFn(K, @This()); }; } +/// A HashMap type that uses offsets rather than pointers, making it +/// possible to efficiently move around the backing memory without +/// invalidating the HashMap. pub fn OffsetHashMap( comptime K: type, comptime V: type, @@ -64,19 +67,37 @@ pub fn OffsetHashMap( return struct { const Self = @This(); + /// This is the pointer-based map that we're wrapping. pub const Unmanaged = HashMapUnmanaged(K, V, Context); + /// This is the alignment that the base pointer must have. + pub const base_align = Unmanaged.max_align; + metadata: Offset(Unmanaged.Metadata) = .{}, size: Unmanaged.Size = 0, + /// Returns the total size of the backing memory required for a + /// HashMap with the given capacity. The base ptr must also be + /// aligned to base_align. + pub fn bufferSize(cap: Unmanaged.Size) usize { + const layout = Unmanaged.layoutForCapacity(cap); + return layout.total_size; + } + + /// Initialize a new HashMap with the given capacity and backing + /// memory. The backing memory must be aligned to base_align. pub fn init(cap: Unmanaged.Size, buf: []u8) Self { + assert(@intFromPtr(buf.ptr) % base_align == 0); + const m = Unmanaged.init(cap, buf); + const offset = @intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr); return .{ - .metadata = .{ .offset = @intCast(@intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr)) }, + .metadata = .{ .offset = @intCast(offset) }, .size = m.size, }; } + /// Returns the pointer-based map from a base pointer. pub fn map(self: Self, base: anytype) Unmanaged { return .{ .metadata = self.metadata.ptr(base), @@ -86,16 +107,11 @@ pub fn OffsetHashMap( }; } -/// A HashMap based on open addressing and linear probing. -/// A lookup or modification typically incurs only 2 cache misses. -/// No order is guaranteed and any modification invalidates live iterators. -/// It achieves good performance with quite high load factors (by default, -/// grow is triggered at 80% full) and only one byte of overhead per element. -/// The struct itself is only 16 bytes for a small footprint. This comes at -/// the price of handling size with u32, which should be reasonable enough -/// for almost all uses. -/// Deletions are achieved with tombstones. -pub fn HashMapUnmanaged( +/// Fork of stdlib.HashMap as of Zig 0.12 modified to to use offsets +/// for the key/values pointer. The metadata is still a pointer to limit +/// the amount of arithmetic required to access it. See the file comment +/// for full details. +fn HashMapUnmanaged( comptime K: type, comptime V: type, comptime Context: type, @@ -294,10 +310,6 @@ pub fn HashMapUnmanaged( return map; } - pub fn capacityForSize(size: Size) Size { - return math.ceilPowerOfTwo(u32, size + 1) catch unreachable; - } - pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { if (new_size > self.size) try self.growIfNeeded(new_size - self.size); } @@ -828,7 +840,7 @@ pub fn HashMapUnmanaged( } /// The memory layout for the underlying buffer for a given capacity. - pub const Layout = struct { + const Layout = struct { /// The total size of the buffer required. The buffer is expected /// to be aligned to `max_align`. total_size: usize, @@ -844,7 +856,7 @@ pub fn HashMapUnmanaged( /// The actual size may be able to fit more than the given capacity /// because capacity is rounded up to the next power of two. This is /// a design requirement for this hash map implementation. - pub fn layoutForCapacity(new_capacity: Size) Layout { + fn layoutForCapacity(new_capacity: Size) Layout { assert(std.math.isPowerOfTwo(new_capacity)); const meta_size = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); comptime assert(@alignOf(Metadata) == 1); From 6ffe66e7283d4df462e5078f0cefc9a8dedf1bd5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 17 Feb 2024 11:57:37 -0800 Subject: [PATCH 010/428] terminal/new: getOffset --- src/terminal/new/size.zig | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 305b9ff396..47cf23edf4 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -35,6 +35,19 @@ pub fn Offset(comptime T: type) type { }; } +/// Get the offset for a given type from some base pointer to the +/// actual pointer to the type. +pub fn getOffset( + comptime T: type, + base: anytype, + ptr: *const T, +) Offset(T) { + const base_int = @intFromPtr(base); + const ptr_int = @intFromPtr(ptr); + const offset = ptr_int - base_int; + return .{ .offset = @intCast(offset) }; +} + test "Offset" { // This test is here so that if Offset changes, we can be very aware // of this effect and think about the implications of it. @@ -59,3 +72,27 @@ test "Offset ptr structural" { const actual = offset.ptr(base); try testing.expectEqual(@as(usize, base_int + offset.offset), @intFromPtr(actual)); } + +test "getOffset bytes" { + const testing = std.testing; + var widgets: []const u8 = "ABCD"; + const offset = getOffset(u8, widgets.ptr, &widgets[2]); + try testing.expectEqual(@as(OffsetInt, 2), offset.offset); +} + +test "getOffset structs" { + const testing = std.testing; + const Widget = struct { x: u32, y: u32 }; + const widgets: []const Widget = &.{ + .{ .x = 1, .y = 2 }, + .{ .x = 3, .y = 4 }, + .{ .x = 5, .y = 6 }, + .{ .x = 7, .y = 8 }, + .{ .x = 9, .y = 10 }, + }; + const offset = getOffset(Widget, widgets.ptr, &widgets[2]); + try testing.expectEqual( + @as(OffsetInt, @sizeOf(Widget) * 2), + offset.offset, + ); +} From 040d07d47621be1cb9b760dab0c5e8ec102d6ef5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 17 Feb 2024 18:49:57 -0800 Subject: [PATCH 011/428] terminal/new: nothing works but everything looks right --- src/terminal/new/page.zig | 14 ++++ src/terminal/new/size.zig | 19 ++++- src/terminal/new/style.zig | 140 +++++++++++++++++++++++++++++++++++-- 3 files changed, 165 insertions(+), 8 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 51851ec70a..d37661f948 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -2,8 +2,11 @@ const std = @import("std"); const assert = std.debug.assert; const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); +const style = @import("style.zig"); const size = @import("size.zig"); const Offset = size.Offset; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be @@ -93,3 +96,14 @@ test { _ = Page; _ = Style; } + +// test { +// const testing = std.testing; +// const cap = try std.math.ceilPowerOfTwo(usize, 350); +// const StyleIdMap = AutoOffsetHashMap(size.CellCountInt, style.Style); +// const StyleMetadataMap = AutoOffsetHashMap(style.Style, style.Metadata); +// +// var len = StyleIdMap.bufferSize(@intCast(cap)); +// len += StyleMetadataMap.bufferSize(@intCast(cap)); +// try testing.expectEqual(@as(usize, 0), len); +// } diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 47cf23edf4..01a8bece1b 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -30,7 +30,7 @@ pub fn Offset(comptime T: type) type { // our return type is naturally aligned. We COULD modify this // to return arbitrary alignment, but its not something we need. assert(@mod(self.offset, @alignOf(T)) == 0); - return @ptrFromInt(@intFromPtr(base) + self.offset); + return @ptrFromInt(intFromBase(base) + self.offset); } }; } @@ -42,12 +42,27 @@ pub fn getOffset( base: anytype, ptr: *const T, ) Offset(T) { - const base_int = @intFromPtr(base); + const base_int = intFromBase(base); const ptr_int = @intFromPtr(ptr); const offset = ptr_int - base_int; return .{ .offset = @intCast(offset) }; } +fn intFromBase(base: anytype) usize { + return switch (@typeInfo(@TypeOf(base))) { + .Pointer => |v| switch (v.size) { + .One, + .Many, + .C, + => @intFromPtr(base), + + .Slice => @intFromPtr(base.ptr), + }, + + else => @compileError("invalid base type"), + }; +} + test "Offset" { // This test is here so that if Offset changes, we can be very aware // of this effect and think about the implications of it. diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 6fdb8c3e73..5499729756 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -1,7 +1,11 @@ const std = @import("std"); +const assert = std.debug.assert; const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const size = @import("size.zig"); +const Offset = size.Offset; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. @@ -43,20 +47,144 @@ pub const Style = struct { } }; -/// Maps a style definition to metadata about that style. -pub const MetadataMap = std.AutoHashMapUnmanaged(Style, Metadata); +/// A set of styles. +pub const Set = struct { + /// The mapping of a style to associated metadata. This is + /// the map that contains the actual style definitions + /// (in the form of the key). + styles: MetadataMap, -/// Maps the unique style ID to the concrete style definition. -pub const IdMap = std.AutoHashMapUnmanaged(size.CellCountInt, Style); + /// The mapping from ID to style. + id_map: IdMap, + + /// The next ID to use for a style that isn't in the set. + /// When this overflows we'll begin returning an IdOverflow + /// error and the caller must manually compact the style + /// set. + next_id: Id = 1, + + /// Maps a style definition to metadata about that style. + const MetadataMap = AutoOffsetHashMap(Style, Metadata); + + /// Maps the unique style ID to the concrete style definition. + const IdMap = AutoOffsetHashMap(Id, Offset(Style)); + + /// Returns the memory layout for the given base offset and + /// desired capacity. The layout can be used by the caller to + /// determine how much memory to allocate, and the layout must + /// be used to initialize the set so that the set knows all + /// the offsets for the various buffers. + pub fn layoutForCapacity(base: usize, cap: usize) Layout { + const md_start = std.mem.alignForward(usize, base, MetadataMap.base_align); + const md_end = md_start + MetadataMap.bufferSize(@intCast(cap)); + + const id_start = std.mem.alignForward(usize, md_end, IdMap.base_align); + const id_end = id_start + IdMap.bufferSize(@intCast(cap)); + + const total_size = id_end - base; + + return .{ + .cap = cap, + .md_start = md_start, + .id_start = id_start, + .total_size = total_size, + }; + } + + pub const Layout = struct { + cap: usize, + md_start: usize, + id_start: usize, + total_size: usize, + }; + + pub fn init(base: []u8, layout: Layout) Set { + assert(base.len >= layout.total_size); + + var styles = MetadataMap.init(@intCast(layout.cap), base[layout.md_start..]); + styles.metadata.offset += @intCast(layout.md_start); + + var id_map = IdMap.init(@intCast(layout.cap), base[layout.id_start..]); + id_map.metadata.offset += @intCast(layout.id_start); + + return .{ + .styles = styles, + .id_map = id_map, + }; + } + + /// Upsert a style into the set and return a pointer to the metadata + /// for that style. The pointer is valid for the lifetime of the set + /// so long as the style is not removed. + pub fn upsert(self: *Set, base: anytype, style: Style) !*Metadata { + // If we already have the style in the map, this is fast. + var map = self.styles.map(base); + const gop = try map.getOrPut(style); + if (gop.found_existing) return gop.value_ptr; + + // New style, we need to setup all the metadata. First thing, + // let's get the ID we'll assign, because if we're out of space + // we need to fail early. + errdefer map.removeByPtr(gop.key_ptr); + const id = self.next_id; + self.next_id = try std.math.add(Id, self.next_id, 1); + errdefer self.next_id -= 1; + gop.value_ptr.* = .{ .id = id }; + + // Setup our ID mapping + var id_map = self.id_map.map(base); + const id_gop = try id_map.getOrPut(id); + errdefer id_map.removeByPtr(id_gop.key_ptr); + assert(!id_gop.found_existing); + id_gop.value_ptr.* = size.getOffset(Style, base, gop.key_ptr); + return gop.value_ptr; + } + + /// Lookup a style by its unique identifier. + pub fn lookupId(self: *const Set, base: anytype, id: Id) ?*Style { + const id_map = self.id_map.map(base); + const offset = id_map.get(id) orelse return null; + return @ptrCast(offset.ptr(base)); + } +}; /// Metadata about a style. This is used to track the reference count /// and the unique identifier for a style. The unique identifier is used /// to track the style in the full style map. pub const Metadata = struct { - ref: size.CellCountInt = 0, - id: size.CellCountInt = 0, + ref: size.CellCountInt = 1, + id: Id = 0, }; test { _ = Style; + _ = Set; +} + +test "Set basic usage" { + const testing = std.testing; + const alloc = testing.allocator; + const layout = Set.layoutForCapacity(0, 16); + const buf = try alloc.alloc(u8, layout.total_size); + defer alloc.free(buf); + + const style: Style = .{ .flags = .{ .bold = true } }; + + var set = Set.init(buf, layout); + + // Upsert + const meta = try set.upsert(buf, style); + try testing.expect(meta.id > 0); + + // Second upsert should return the same metadata. + { + const meta2 = try set.upsert(buf, style); + try testing.expectEqual(meta.id, meta2.id); + } + + // Look it up + { + const v = set.lookupId(buf, meta.id).?; + try testing.expect(v.flags.bold); + } } From 4fa558735c4d890aa7762daee8c81b170680d4c4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 09:49:05 -0800 Subject: [PATCH 012/428] terminal/new: hash map size is part of buffer --- src/terminal/new/hash_map.zig | 110 ++++++++++++++++++++++------------ src/terminal/new/size.zig | 29 ++++++++- src/terminal/new/style.zig | 54 ++++++++--------- 3 files changed, 125 insertions(+), 68 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 0aba74806e..4c0addb71a 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -40,6 +40,7 @@ const Allocator = mem.Allocator; const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; +const OffsetBuf = @import("size.zig").OffsetBuf; pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { return OffsetHashMap(K, V, AutoContext(K)); @@ -71,10 +72,9 @@ pub fn OffsetHashMap( pub const Unmanaged = HashMapUnmanaged(K, V, Context); /// This is the alignment that the base pointer must have. - pub const base_align = Unmanaged.max_align; + pub const base_align = Unmanaged.base_align; metadata: Offset(Unmanaged.Metadata) = .{}, - size: Unmanaged.Size = 0, /// Returns the total size of the backing memory required for a /// HashMap with the given capacity. The base ptr must also be @@ -91,18 +91,12 @@ pub fn OffsetHashMap( const m = Unmanaged.init(cap, buf); const offset = @intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr); - return .{ - .metadata = .{ .offset = @intCast(offset) }, - .size = m.size, - }; + return .{ .metadata = .{ .offset = @intCast(offset) } }; } /// Returns the pointer-based map from a base pointer. pub fn map(self: Self, base: anytype) Unmanaged { - return .{ - .metadata = self.metadata.ptr(base), - .size = self.size, - }; + return .{ .metadata = self.metadata.ptr(base) }; } }; } @@ -121,12 +115,13 @@ fn HashMapUnmanaged( comptime { std.hash_map.verifyContext(Context, K, K, u64, false); + assert(@alignOf(Metadata) == 1); } const header_align = @alignOf(Header); const key_align = if (@sizeOf(K) == 0) 1 else @alignOf(K); const val_align = if (@sizeOf(V) == 0) 1 else @alignOf(V); - const max_align = @max(header_align, key_align, val_align); + const base_align = @max(header_align, key_align, val_align); // This is actually a midway pointer to the single buffer containing // a `Header` field, the `Metadata`s and `Entry`s. @@ -138,9 +133,6 @@ fn HashMapUnmanaged( /// Pointer to the metadata. metadata: ?[*]Metadata = null, - /// Current number of elements in the hashmap. - size: Size = 0, - // This is purely empirical and not a /very smart magic constant™/. /// Capacity of the first grow when bootstrapping the hashmap. const minimal_capacity = 8; @@ -163,9 +155,11 @@ fn HashMapUnmanaged( }; const Header = struct { + /// The keys/values offset are relative to the metadata values: Offset(V), keys: Offset(K), capacity: Size, + size: Size, }; /// Metadata for a slot. It can be in three states: empty, used or @@ -234,7 +228,7 @@ fn HashMapUnmanaged( pub fn next(it: *Iterator) ?Entry { assert(it.index <= it.hm.capacity()); - if (it.hm.size == 0) return null; + if (it.hm.header().size == 0) return null; const cap = it.hm.capacity(); const end = it.hm.metadata.? + cap; @@ -290,19 +284,18 @@ fn HashMapUnmanaged( /// Initialize a hash map with a given capacity and a buffer. The /// buffer must fit within the size defined by `layoutForCapacity`. pub fn init(new_capacity: Size, buf: []u8) Self { + assert(@intFromPtr(buf.ptr) % base_align == 0); const layout = layoutForCapacity(new_capacity); - - // Ensure our base pointer is aligned to the max alignment - const base = std.mem.alignForward(usize, @intFromPtr(buf.ptr), max_align); - assert(base >= layout.total_size); + assert(buf.len >= layout.total_size); // Get all our main pointers - const metadata_ptr: [*]Metadata = @ptrFromInt(base + @sizeOf(Header)); + const metadata_ptr: [*]Metadata = @ptrFromInt(@intFromPtr(buf.ptr) + @sizeOf(Header)); // Build our map var map: Self = .{ .metadata = metadata_ptr }; const hdr = map.header(); hdr.capacity = new_capacity; + hdr.size = 0; if (@sizeOf([*]K) != 0) hdr.keys = .{ .offset = @intCast(layout.keys_start) }; if (@sizeOf([*]V) != 0) hdr.values = .{ .offset = @intCast(layout.vals_start) }; map.initMetadatas(); @@ -311,7 +304,9 @@ fn HashMapUnmanaged( } pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { - if (new_size > self.size) try self.growIfNeeded(new_size - self.size); + if (new_size > self.header().size) { + try self.growIfNeeded(new_size - self.header().size); + } } pub fn ensureUnusedCapacity(self: *Self, additional_size: Size) Allocator.Error!void { @@ -321,12 +316,12 @@ fn HashMapUnmanaged( pub fn clearRetainingCapacity(self: *Self) void { if (self.metadata) |_| { self.initMetadatas(); - self.size = 0; + self.header().size = 0; } } pub fn count(self: *const Self) Size { - return self.size; + return self.header().size; } fn header(self: *const Self) *Header { @@ -433,8 +428,7 @@ fn HashMapUnmanaged( metadata[0].fill(fingerprint); self.keys()[idx] = key; self.values()[idx] = value; - - self.size += 1; + self.header().size += 1; } /// Inserts a new `Entry` into the hash map, returning the previous one, if any. @@ -497,7 +491,7 @@ fn HashMapUnmanaged( self.metadata.?[idx].remove(); old_key.* = undefined; old_val.* = undefined; - self.size -= 1; + self.header().size -= 1; return result; } @@ -515,7 +509,7 @@ fn HashMapUnmanaged( inline fn getIndex(self: Self, key: anytype, ctx: anytype) ?usize { comptime std.hash_map.verifyContext(@TypeOf(ctx), @TypeOf(key), K, Hash, false); - if (self.size == 0) { + if (self.header().size == 0) { return null; } @@ -751,7 +745,7 @@ fn HashMapUnmanaged( const new_value = &self.values()[idx]; new_key.* = undefined; new_value.* = undefined; - self.size += 1; + self.header().size += 1; return GetOrPutResult{ .key_ptr = new_key, @@ -791,7 +785,7 @@ fn HashMapUnmanaged( self.metadata.?[idx].remove(); self.keys()[idx] = undefined; self.values()[idx] = undefined; - self.size -= 1; + self.header().size -= 1; } /// If there is an `Entry` with a matching key, it is deleted from @@ -835,14 +829,14 @@ fn HashMapUnmanaged( } fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { - const available = self.capacity() - self.size; + const available = self.capacity() - self.header().size; if (new_count > available) return error.OutOfMemory; } /// The memory layout for the underlying buffer for a given capacity. const Layout = struct { /// The total size of the buffer required. The buffer is expected - /// to be aligned to `max_align`. + /// to be aligned to `base_align`. total_size: usize, /// The offset to the start of the keys data. @@ -850,6 +844,9 @@ fn HashMapUnmanaged( /// The offset to the start of the values data. vals_start: usize, + + /// The capacity that was used to calculate this layout. + capacity: Size, }; /// Returns the memory layout for the buffer for a given capacity. @@ -858,20 +855,30 @@ fn HashMapUnmanaged( /// a design requirement for this hash map implementation. fn layoutForCapacity(new_capacity: Size) Layout { assert(std.math.isPowerOfTwo(new_capacity)); - const meta_size = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); - comptime assert(@alignOf(Metadata) == 1); - const keys_start = std.mem.alignForward(usize, meta_size, key_align); + // Pack our metadata, keys, and values. + const meta_start = @sizeOf(Header); + const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const keys_start = std.mem.alignForward(usize, meta_end, key_align); const keys_end = keys_start + new_capacity * @sizeOf(K); - const vals_start = std.mem.alignForward(usize, keys_end, val_align); const vals_end = vals_start + new_capacity * @sizeOf(V); - const total_size = std.mem.alignForward(usize, vals_end, max_align); + // Our total memory size required is the end of our values + // aligned to the base required alignment. + const total_size = std.mem.alignForward(usize, vals_end, base_align); + + // The offsets we actually store in the map are from the + // metadata pointer so that we can use self.metadata as + // the base. + const keys_offset = keys_start - meta_start; + const vals_offset = vals_start - meta_start; + return .{ .total_size = total_size, - .keys_start = keys_start, - .vals_start = vals_start, + .keys_start = keys_offset, + .vals_start = vals_offset, + .capacity = new_capacity, }; } }; @@ -1177,7 +1184,11 @@ test "HashMap put full load" { const cap = 16; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const buf = try alloc.alignedAlloc( + u8, + Map.base_align, + Map.layoutForCapacity(cap).total_size, + ); defer alloc.free(buf); var map = Map.init(cap, buf); @@ -1461,3 +1472,24 @@ test "OffsetHashMap basic usage" { } try expectEqual(total, sum); } + +test "OffsetHashMap remake map" { + const OffsetMap = AutoOffsetHashMap(u32, u32); + + const alloc = testing.allocator; + const cap = 16; + const buf = try alloc.alloc(u8, OffsetMap.Unmanaged.layoutForCapacity(cap).total_size); + defer alloc.free(buf); + + var offset_map = OffsetMap.init(cap, buf); + + { + var map = offset_map.map(buf.ptr); + try map.put(5, 5); + } + + { + var map = offset_map.map(buf.ptr); + try expectEqual(5, map.get(5).?); + } +} diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 01a8bece1b..deaa56b6b1 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -29,12 +29,37 @@ pub fn Offset(comptime T: type) type { // The offset must be properly aligned for the type since // our return type is naturally aligned. We COULD modify this // to return arbitrary alignment, but its not something we need. - assert(@mod(self.offset, @alignOf(T)) == 0); - return @ptrFromInt(intFromBase(base) + self.offset); + const addr = intFromBase(base) + self.offset; + assert(addr % @alignOf(T) == 0); + return @ptrFromInt(addr); } }; } +/// A type that is used to intitialize offset-based structures. +/// This allows for tracking the base pointer, the offset into +/// the base pointer we're starting, and the memory layout of +/// components. +pub const OffsetBuf = struct { + /// The true base pointer to the backing memory. This is + /// "byte zero" of the allocation. This plus the offset make + /// it easy to pass in the base pointer in all usage to this + /// structure and the offsets are correct. + base: [*]u8 = 0, + + /// Offset from base where the beginning of /this/ data + /// structure is located. We use this so that we can slowly + /// build up a chain of offset-based structures but always + /// have the base pointer sent into functions be the true base. + offset: usize = 0, + + pub fn offsetBase(comptime T: type, self: OffsetBuf) [*]T { + const ptr = self.base + self.offset; + assert(@intFromPtr(ptr) % @alignOf(T) == 0); + return @ptrCast(ptr); + } +}; + /// Get the offset for a given type from some base pointer to the /// actual pointer to the type. pub fn getOffset( diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 5499729756..78f6b5c4dc 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -161,30 +161,30 @@ test { _ = Set; } -test "Set basic usage" { - const testing = std.testing; - const alloc = testing.allocator; - const layout = Set.layoutForCapacity(0, 16); - const buf = try alloc.alloc(u8, layout.total_size); - defer alloc.free(buf); - - const style: Style = .{ .flags = .{ .bold = true } }; - - var set = Set.init(buf, layout); - - // Upsert - const meta = try set.upsert(buf, style); - try testing.expect(meta.id > 0); - - // Second upsert should return the same metadata. - { - const meta2 = try set.upsert(buf, style); - try testing.expectEqual(meta.id, meta2.id); - } - - // Look it up - { - const v = set.lookupId(buf, meta.id).?; - try testing.expect(v.flags.bold); - } -} +// test "Set basic usage" { +// const testing = std.testing; +// const alloc = testing.allocator; +// const layout = Set.layoutForCapacity(0, 16); +// const buf = try alloc.alloc(u8, layout.total_size); +// defer alloc.free(buf); +// +// const style: Style = .{ .flags = .{ .bold = true } }; +// +// var set = Set.init(buf, layout); +// +// // Upsert +// const meta = try set.upsert(buf, style); +// try testing.expect(meta.id > 0); +// +// // Second upsert should return the same metadata. +// { +// const meta2 = try set.upsert(buf, style); +// try testing.expectEqual(meta.id, meta2.id); +// } +// +// // Look it up +// { +// const v = set.lookupId(buf, meta.id).?; +// try testing.expect(v.flags.bold); +// } +// } From fba9d5ab619dc2b4a4aa87ba56bc22526cb8dae5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 09:50:07 -0800 Subject: [PATCH 013/428] terminal/new: style tests --- src/terminal/new/style.zig | 57 ++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 78f6b5c4dc..a9d9497450 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -161,30 +161,33 @@ test { _ = Set; } -// test "Set basic usage" { -// const testing = std.testing; -// const alloc = testing.allocator; -// const layout = Set.layoutForCapacity(0, 16); -// const buf = try alloc.alloc(u8, layout.total_size); -// defer alloc.free(buf); -// -// const style: Style = .{ .flags = .{ .bold = true } }; -// -// var set = Set.init(buf, layout); -// -// // Upsert -// const meta = try set.upsert(buf, style); -// try testing.expect(meta.id > 0); -// -// // Second upsert should return the same metadata. -// { -// const meta2 = try set.upsert(buf, style); -// try testing.expectEqual(meta.id, meta2.id); -// } -// -// // Look it up -// { -// const v = set.lookupId(buf, meta.id).?; -// try testing.expect(v.flags.bold); -// } -// } +test "Set basic usage" { + const testing = std.testing; + const alloc = testing.allocator; + const layout = Set.layoutForCapacity(0, 16); + const buf = try alloc.alloc(u8, layout.total_size); + defer alloc.free(buf); + + const style: Style = .{ .flags = .{ .bold = true } }; + + var set = Set.init(buf, layout); + + // Upsert + const meta = try set.upsert(buf, style); + try testing.expect(meta.id > 0); + + // Second upsert should return the same metadata. + { + const meta2 = try set.upsert(buf, style); + try testing.expectEqual(meta.id, meta2.id); + } + + // Look it up + { + const v = set.lookupId(buf, meta.id).?; + try testing.expect(v.flags.bold); + + const v2 = set.lookupId(buf, meta.id).?; + try testing.expectEqual(v, v2); + } +} From 24354d8392e3f73ee4ff29dc3fc4ca9f2392d28d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 10:01:12 -0800 Subject: [PATCH 014/428] terminal/new: style set removal --- src/terminal/new/style.zig | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index a9d9497450..a34d6b706b 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -146,6 +146,21 @@ pub const Set = struct { const offset = id_map.get(id) orelse return null; return @ptrCast(offset.ptr(base)); } + + /// Remove a style by its id. + pub fn remove(self: *Set, base: anytype, id: Id) void { + // Lookup by ID, if it doesn't exist then we return. We use + // getEntry so that we can make removal faster later by using + // the entry's key pointer. + var id_map = self.id_map.map(base); + const id_entry = id_map.getEntry(id) orelse return; + + var style_map = self.styles.map(base); + const style_ptr: *Style = @ptrCast(id_entry.value_ptr.ptr(base)); + + id_map.removeByPtr(id_entry.key_ptr); + style_map.removeByPtr(style_ptr); + } }; /// Metadata about a style. This is used to track the reference count @@ -190,4 +205,8 @@ test "Set basic usage" { const v2 = set.lookupId(buf, meta.id).?; try testing.expectEqual(v, v2); } + + // Removal + set.remove(buf, meta.id); + try testing.expect(set.lookupId(buf, meta.id) == null); } From 334f651387bc6b4b3b0ed825002c7014376eeed3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 14:55:29 -0800 Subject: [PATCH 015/428] terminal/new: everything is OffsetBuf based --- src/terminal/new/hash_map.zig | 151 +++++++++++++++++++--------------- src/terminal/new/size.zig | 64 ++++++++++++-- src/terminal/new/style.zig | 43 +++++----- 3 files changed, 161 insertions(+), 97 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index 4c0addb71a..a6433260bb 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -41,6 +41,7 @@ const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; const OffsetBuf = @import("size.zig").OffsetBuf; +const getOffset = @import("size.zig").getOffset; pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { return OffsetHashMap(K, V, AutoContext(K)); @@ -70,6 +71,7 @@ pub fn OffsetHashMap( /// This is the pointer-based map that we're wrapping. pub const Unmanaged = HashMapUnmanaged(K, V, Context); + pub const Layout = Unmanaged.Layout; /// This is the alignment that the base pointer must have. pub const base_align = Unmanaged.base_align; @@ -79,19 +81,21 @@ pub fn OffsetHashMap( /// Returns the total size of the backing memory required for a /// HashMap with the given capacity. The base ptr must also be /// aligned to base_align. - pub fn bufferSize(cap: Unmanaged.Size) usize { - const layout = Unmanaged.layoutForCapacity(cap); - return layout.total_size; + pub fn layout(cap: Unmanaged.Size) Layout { + return Unmanaged.layoutForCapacity(cap); } /// Initialize a new HashMap with the given capacity and backing /// memory. The backing memory must be aligned to base_align. - pub fn init(cap: Unmanaged.Size, buf: []u8) Self { - assert(@intFromPtr(buf.ptr) % base_align == 0); + pub fn init(buf: OffsetBuf, l: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); - const m = Unmanaged.init(cap, buf); - const offset = @intFromPtr(m.metadata.?) - @intFromPtr(buf.ptr); - return .{ .metadata = .{ .offset = @intCast(offset) } }; + const m = Unmanaged.init(buf, l); + return .{ .metadata = getOffset( + Unmanaged.Metadata, + buf, + @ptrCast(m.metadata.?), + ) }; } /// Returns the pointer-based map from a base pointer. @@ -283,21 +287,19 @@ fn HashMapUnmanaged( /// Initialize a hash map with a given capacity and a buffer. The /// buffer must fit within the size defined by `layoutForCapacity`. - pub fn init(new_capacity: Size, buf: []u8) Self { - assert(@intFromPtr(buf.ptr) % base_align == 0); - const layout = layoutForCapacity(new_capacity); - assert(buf.len >= layout.total_size); + pub fn init(buf: OffsetBuf, layout: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); // Get all our main pointers - const metadata_ptr: [*]Metadata = @ptrFromInt(@intFromPtr(buf.ptr) + @sizeOf(Header)); + const metadata_ptr: [*]Metadata = @ptrCast(buf.start() + @sizeOf(Header)); // Build our map var map: Self = .{ .metadata = metadata_ptr }; const hdr = map.header(); - hdr.capacity = new_capacity; + hdr.capacity = layout.capacity; hdr.size = 0; - if (@sizeOf([*]K) != 0) hdr.keys = .{ .offset = @intCast(layout.keys_start) }; - if (@sizeOf([*]V) != 0) hdr.values = .{ .offset = @intCast(layout.vals_start) }; + if (@sizeOf([*]K) != 0) hdr.keys = buf.member(K, layout.keys_start); + if (@sizeOf([*]V) != 0) hdr.values = buf.member(V, layout.vals_start); map.initMetadatas(); return map; @@ -853,7 +855,7 @@ fn HashMapUnmanaged( /// The actual size may be able to fit more than the given capacity /// because capacity is rounded up to the next power of two. This is /// a design requirement for this hash map implementation. - fn layoutForCapacity(new_capacity: Size) Layout { + pub fn layoutForCapacity(new_capacity: Size) Layout { assert(std.math.isPowerOfTwo(new_capacity)); // Pack our metadata, keys, and values. @@ -893,10 +895,11 @@ test "HashMap basic usage" { const alloc = testing.allocator; const cap = 16; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); const count = 5; var i: u32 = 0; @@ -927,9 +930,10 @@ test "HashMap ensureTotalCapacity" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); const initial_capacity = map.capacity(); try testing.expect(initial_capacity >= 20); @@ -946,9 +950,10 @@ test "HashMap ensureUnusedCapacity with tombstones" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: i32 = 0; while (i < 100) : (i += 1) { @@ -963,9 +968,10 @@ test "HashMap clearRetainingCapacity" { const cap = 16; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); map.clearRetainingCapacity(); @@ -993,9 +999,10 @@ test "HashMap ensureTotalCapacity with existing elements" { const cap = Map.minimal_capacity; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); try map.put(0, 0); try expectEqual(map.count(), 1); @@ -1011,9 +1018,10 @@ test "HashMap remove" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1048,9 +1056,10 @@ test "HashMap reverse removes" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1075,9 +1084,10 @@ test "HashMap multiple removes on same metadata" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1117,9 +1127,10 @@ test "HashMap put and remove loop in random order" { const cap = 64; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var keys = std.ArrayList(u32).init(alloc); defer keys.deinit(); @@ -1154,9 +1165,10 @@ test "HashMap put" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1184,13 +1196,10 @@ test "HashMap put full load" { const cap = 16; const alloc = testing.allocator; - const buf = try alloc.alignedAlloc( - u8, - Map.base_align, - Map.layoutForCapacity(cap).total_size, - ); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); for (0..cap) |i| try map.put(i, i); for (0..cap) |i| try expectEqual(map.get(i).?, i); @@ -1203,9 +1212,10 @@ test "HashMap putAssumeCapacity" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 20) : (i += 1) { @@ -1237,9 +1247,10 @@ test "HashMap repeat putAssumeCapacity/remove" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); const limit = cap; @@ -1272,9 +1283,10 @@ test "HashMap getOrPut" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: u32 = 0; while (i < 10) : (i += 1) { @@ -1300,9 +1312,10 @@ test "HashMap basic hash map usage" { const cap = 32; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); try testing.expect((try map.fetchPut(1, 11)) == null); try testing.expect((try map.fetchPut(2, 22)) == null); @@ -1350,9 +1363,10 @@ test "HashMap ensureUnusedCapacity" { const cap = 64; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); try map.ensureUnusedCapacity(32); try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); @@ -1363,9 +1377,10 @@ test "HashMap removeByPtr" { const cap = 64; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); var i: i32 = undefined; i = 0; @@ -1393,9 +1408,10 @@ test "HashMap removeByPtr 0 sized key" { const cap = 64; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); try map.put(0, 0); @@ -1416,9 +1432,10 @@ test "HashMap repeat fetchRemove" { const cap = 64; const alloc = testing.allocator; - const buf = try alloc.alloc(u8, Map.layoutForCapacity(cap).total_size); + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(cap, buf); + var map = Map.init(OffsetBuf.init(buf), layout); map.putAssumeCapacity(0, {}); map.putAssumeCapacity(1, {}); @@ -1440,13 +1457,13 @@ test "HashMap repeat fetchRemove" { test "OffsetHashMap basic usage" { const OffsetMap = AutoOffsetHashMap(u32, u32); + const cap = 16; const alloc = testing.allocator; - const cap = 16; - const buf = try alloc.alloc(u8, OffsetMap.Unmanaged.layoutForCapacity(cap).total_size); + const layout = OffsetMap.layout(cap); + const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - - var offset_map = OffsetMap.init(cap, buf); + var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); var map = offset_map.map(buf.ptr); const count = 5; @@ -1475,13 +1492,13 @@ test "OffsetHashMap basic usage" { test "OffsetHashMap remake map" { const OffsetMap = AutoOffsetHashMap(u32, u32); + const cap = 16; const alloc = testing.allocator; - const cap = 16; - const buf = try alloc.alloc(u8, OffsetMap.Unmanaged.layoutForCapacity(cap).total_size); + const layout = OffsetMap.layout(cap); + const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - - var offset_map = OffsetMap.init(cap, buf); + var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); { var map = offset_map.map(buf.ptr); diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index deaa56b6b1..092dcd72da 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -36,16 +36,23 @@ pub fn Offset(comptime T: type) type { }; } -/// A type that is used to intitialize offset-based structures. -/// This allows for tracking the base pointer, the offset into -/// the base pointer we're starting, and the memory layout of -/// components. +/// Represents a buffer that is offset from some base pointer. +/// Offset-based structures should use this as their initialization +/// parameter so that they can know what segment of memory they own +/// while at the same time initializing their offset fields to be +/// against the true base. +/// +/// The term "true base" is used to describe the base address of +/// the allocation, which i.e. can include memory that you do NOT +/// own and is used by some other structures. All offsets are against +/// this "true base" so that to determine addresses structures don't +/// need to add up all the intermediary offsets. pub const OffsetBuf = struct { /// The true base pointer to the backing memory. This is /// "byte zero" of the allocation. This plus the offset make /// it easy to pass in the base pointer in all usage to this /// structure and the offsets are correct. - base: [*]u8 = 0, + base: [*]u8, /// Offset from base where the beginning of /this/ data /// structure is located. We use this so that we can slowly @@ -53,11 +60,45 @@ pub const OffsetBuf = struct { /// have the base pointer sent into functions be the true base. offset: usize = 0, - pub fn offsetBase(comptime T: type, self: OffsetBuf) [*]T { + /// Initialize a zero-offset buffer from a base. + pub fn init(base: anytype) OffsetBuf { + return initOffset(base, 0); + } + + /// Initialize from some base pointer and offset. + pub fn initOffset(base: anytype, offset: usize) OffsetBuf { + return .{ + .base = @ptrFromInt(intFromBase(base)), + .offset = offset, + }; + } + + /// The base address for the start of the data for the user + /// of this OffsetBuf. This is where your data structure should + /// begin; anything before this is NOT your memory. + pub fn start(self: OffsetBuf) [*]u8 { const ptr = self.base + self.offset; - assert(@intFromPtr(ptr) % @alignOf(T) == 0); return @ptrCast(ptr); } + + /// Returns an Offset calculation for some child member of + /// your struct. The offset is against the true base pointer + /// so that future callers can pass that in as the base. + pub fn member( + self: OffsetBuf, + comptime T: type, + len: usize, + ) Offset(T) { + return .{ .offset = @intCast(self.offset + len) }; + } + + /// Add an offset to the current offset. + pub fn add(self: OffsetBuf, offset: usize) OffsetBuf { + return .{ + .base = self.base, + .offset = self.offset + offset, + }; + } }; /// Get the offset for a given type from some base pointer to the @@ -74,7 +115,8 @@ pub fn getOffset( } fn intFromBase(base: anytype) usize { - return switch (@typeInfo(@TypeOf(base))) { + const T = @TypeOf(base); + return switch (@typeInfo(T)) { .Pointer => |v| switch (v.size) { .One, .Many, @@ -84,7 +126,11 @@ fn intFromBase(base: anytype) usize { .Slice => @intFromPtr(base.ptr), }, - else => @compileError("invalid base type"), + else => switch (T) { + OffsetBuf => @intFromPtr(base.base), + + else => @compileError("invalid base type"), + }, }; } diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index a34d6b706b..e65250265e 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -4,6 +4,7 @@ const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const size = @import("size.zig"); const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; @@ -49,6 +50,8 @@ pub const Style = struct { /// A set of styles. pub const Set = struct { + pub const base_align = @max(MetadataMap.base_align, IdMap.base_align); + /// The mapping of a style to associated metadata. This is /// the map that contains the actual style definitions /// (in the form of the key). @@ -74,42 +77,40 @@ pub const Set = struct { /// determine how much memory to allocate, and the layout must /// be used to initialize the set so that the set knows all /// the offsets for the various buffers. - pub fn layoutForCapacity(base: usize, cap: usize) Layout { - const md_start = std.mem.alignForward(usize, base, MetadataMap.base_align); - const md_end = md_start + MetadataMap.bufferSize(@intCast(cap)); + pub fn layout(cap: usize) Layout { + const md_layout = MetadataMap.layout(@intCast(cap)); + const md_start = 0; + const md_end = md_start + md_layout.total_size; + const id_layout = IdMap.layout(@intCast(cap)); const id_start = std.mem.alignForward(usize, md_end, IdMap.base_align); - const id_end = id_start + IdMap.bufferSize(@intCast(cap)); + const id_end = id_start + id_layout.total_size; - const total_size = id_end - base; + const total_size = id_end; return .{ - .cap = cap, .md_start = md_start, + .md_layout = md_layout, .id_start = id_start, + .id_layout = id_layout, .total_size = total_size, }; } pub const Layout = struct { - cap: usize, md_start: usize, + md_layout: MetadataMap.Layout, id_start: usize, + id_layout: IdMap.Layout, total_size: usize, }; - pub fn init(base: []u8, layout: Layout) Set { - assert(base.len >= layout.total_size); - - var styles = MetadataMap.init(@intCast(layout.cap), base[layout.md_start..]); - styles.metadata.offset += @intCast(layout.md_start); - - var id_map = IdMap.init(@intCast(layout.cap), base[layout.id_start..]); - id_map.metadata.offset += @intCast(layout.id_start); - + pub fn init(base: OffsetBuf, l: Layout) Set { + const styles_buf = base.add(l.md_start); + const id_buf = base.add(l.id_start); return .{ - .styles = styles, - .id_map = id_map, + .styles = MetadataMap.init(styles_buf, l.md_layout), + .id_map = IdMap.init(id_buf, l.id_layout), }; } @@ -179,13 +180,13 @@ test { test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; - const layout = Set.layoutForCapacity(0, 16); - const buf = try alloc.alloc(u8, layout.total_size); + const layout = Set.layout(16); + const buf = try alloc.alignedAlloc(u8, Set.base_align, layout.total_size); defer alloc.free(buf); const style: Style = .{ .flags = .{ .bold = true } }; - var set = Set.init(buf, layout); + var set = Set.init(OffsetBuf.init(buf), layout); // Upsert const meta = try set.upsert(buf, style); From 181475eec65c676d36598f399db4a2baba650319 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 15:00:26 -0800 Subject: [PATCH 016/428] terminal/new: clean up comments --- src/terminal/new/style.zig | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index e65250265e..aab12aea78 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -49,6 +49,24 @@ pub const Style = struct { }; /// A set of styles. +/// +/// This set is created with some capacity in mind. You can determine +/// the exact memory requirement for a capacity by calling `layout` +/// and checking the total size. +/// +/// When the set exceeds capacity, `error.OutOfMemory` is returned +/// from memory-using methods. The caller is responsible for determining +/// a path forward. +/// +/// The general idea behind this structure is that it is optimized for +/// the scenario common in terminals where there aren't many unique +/// styles, and many cells are usually drawn with a single style before +/// changing styles. +/// +/// Callers should call `upsert` when a new style is set. This will +/// return a stable pointer to metadata. You should use this metadata +/// to keep a ref count of the style usage. When it falls to zero you +/// can remove it. pub const Set = struct { pub const base_align = @max(MetadataMap.base_align, IdMap.base_align); @@ -117,6 +135,9 @@ pub const Set = struct { /// Upsert a style into the set and return a pointer to the metadata /// for that style. The pointer is valid for the lifetime of the set /// so long as the style is not removed. + /// + /// The ref count for new styles is initialized to zero and + /// for existing styles remains unmodified. pub fn upsert(self: *Set, base: anytype, style: Style) !*Metadata { // If we already have the style in the map, this is fast. var map = self.styles.map(base); @@ -168,15 +189,10 @@ pub const Set = struct { /// and the unique identifier for a style. The unique identifier is used /// to track the style in the full style map. pub const Metadata = struct { - ref: size.CellCountInt = 1, + ref: size.CellCountInt = 0, id: Id = 0, }; -test { - _ = Style; - _ = Set; -} - test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; From 5053a3ab5de0b89d564ba8fffa98c3b0c2502443 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 15:37:17 -0800 Subject: [PATCH 017/428] terminal/new: page init --- src/terminal/new/page.zig | 161 ++++++++++++++++++++++++++----------- src/terminal/new/style.zig | 14 +++- 2 files changed, 125 insertions(+), 50 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index d37661f948..0809796891 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -1,12 +1,15 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const assert = std.debug.assert; const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const style = @import("style.zig"); const size = @import("size.zig"); const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const alignForward = std.mem.alignForward; /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be @@ -24,6 +27,16 @@ const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; /// thoughtfully laid out to optimize primarily for terminal IO (VT streams) /// and to minimize memory usage. pub const Page = struct { + comptime { + // The alignment of our members. We want to ensure that the page + // alignment is always divisible by this. + assert(std.mem.page_size % @max( + @alignOf(Row), + @alignOf(Cell), + style.Set.base_align, + ) == 0); + } + /// The backing memory for the page. A page is always made up of a /// a single contiguous block of memory that is aligned on a page /// boundary and is a multiple of the system page size. @@ -41,6 +54,78 @@ pub const Page = struct { /// to row, you must use the `rows` field. From the pointer to the /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + + /// The available set of styles in use on this page. + styles: style.Set, + + /// Capacity of this page. + pub const Capacity = struct { + /// Number of columns and rows we can know about. + cols: usize, + rows: usize, + + /// Number of unique styles that can be used on this page. + styles: u16, + }; + + /// Initialize a new page, allocating the required backing memory. + /// It is HIGHLY RECOMMENDED you use a page_allocator as the allocator + /// but any allocator is allowed. + pub fn init(alloc: Allocator, cap: Capacity) !Page { + const l = layout(cap); + const backing = try alloc.alignedAlloc(u8, std.mem.page_size, l.total_size); + errdefer alloc.free(backing); + + const buf = OffsetBuf.init(backing); + + return .{ + .memory = backing, + .rows = buf.member(Row, l.rows_start), + .cells = buf.member(Cell, l.cells_start), + .styles = style.Set.init( + buf.add(l.styles_start), + l.styles_layout, + ), + }; + } + + pub fn deinit(self: *Page, alloc: Allocator) void { + alloc.free(self.memory); + self.* = undefined; + } + + pub const Layout = struct { + total_size: usize, + rows_start: usize, + cells_start: usize, + styles_start: usize, + styles_layout: style.Set.Layout, + }; + + /// The memory layout for a page given a desired minimum cols + /// and rows size. + pub fn layout(cap: Capacity) Layout { + const rows_start = 0; + const rows_end = rows_start + (cap.rows * @sizeOf(Row)); + + const cells_count = cap.cols * cap.rows; + const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); + const cells_end = cells_start + (cells_count * @sizeOf(Cell)); + + const styles_layout = style.Set.layout(cap.styles); + const styles_start = alignForward(usize, cells_end, style.Set.base_align); + const styles_end = styles_start + styles_layout.total_size; + + const total_size = styles_end; + + return .{ + .total_size = total_size, + .rows_start = rows_start, + .cells_start = cells_start, + .styles_start = styles_start, + .styles_layout = styles_layout, + }; + } }; pub const Row = packed struct { @@ -54,56 +139,34 @@ pub const Row = packed struct { /// since we zero initialize the backing memory for a page. pub const Cell = packed struct(u32) { codepoint: u21 = 0, + _padding: u11 = 0, }; -/// The style attributes for a cell. -pub const Style = struct { - /// Various colors, all self-explanatory. - fg_color: Color = .none, - bg_color: Color = .none, - underline_color: Color = .none, - - /// On/off attributes that don't require much bit width so we use - /// a packed struct to make this take up significantly less space. - flags: 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, - } = .{}, - - /// The color for an SGR attribute. A color can come from multiple - /// sources so we use this to track the source plus color value so that - /// we can properly react to things like palette changes. - pub const Color = union(enum) { - none: void, - palette: u8, - rgb: color.RGB, - }; - - test { - // The size of the struct so we can be aware of changes. - const testing = std.testing; - try testing.expectEqual(@as(usize, 14), @sizeOf(Style)); - } -}; - -test { - _ = Page; - _ = Style; -} - -// test { -// const testing = std.testing; -// const cap = try std.math.ceilPowerOfTwo(usize, 350); -// const StyleIdMap = AutoOffsetHashMap(size.CellCountInt, style.Style); -// const StyleMetadataMap = AutoOffsetHashMap(style.Style, style.Metadata); +// Uncomment this when you want to do some math. +// test "Page size calculator" { +// const total_size = alignForward( +// usize, +// Page.layout(.{ +// .cols = 333, +// .rows = 81, +// .styles = 32, +// }).total_size, +// std.mem.page_size, +// ); // -// var len = StyleIdMap.bufferSize(@intCast(cap)); -// len += StyleMetadataMap.bufferSize(@intCast(cap)); -// try testing.expectEqual(@as(usize, 0), len); +// std.log.warn("total_size={} pages={}", .{ +// total_size, +// total_size / std.mem.page_size, +// }); // } + +test "Page" { + const testing = std.testing; + const alloc = testing.allocator; + var page = try Page.init(alloc, .{ + .cols = 120, + .rows = 80, + .styles = 32, + }); + defer page.deinit(alloc); +} diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index aab12aea78..668e9f7c8e 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -132,13 +132,25 @@ pub const Set = struct { }; } + /// Possible errors for upsert. + pub const UpsertError = error{ + /// No more space in the backing buffer. Remove styles or + /// grow and reinitialize. + OutOfMemory, + + /// No more available IDs. Perform a garbage collection + /// operation to compact ID space. + /// TODO: implement gc operation + Overflow, + }; + /// Upsert a style into the set and return a pointer to the metadata /// for that style. The pointer is valid for the lifetime of the set /// so long as the style is not removed. /// /// The ref count for new styles is initialized to zero and /// for existing styles remains unmodified. - pub fn upsert(self: *Set, base: anytype, style: Style) !*Metadata { + pub fn upsert(self: *Set, base: anytype, style: Style) UpsertError!*Metadata { // If we already have the style in the map, this is fast. var map = self.styles.map(base); const gop = try map.getOrPut(style); From 24c49f64ad2ecf3f6828e1e23ee014746594683c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 15:55:18 -0800 Subject: [PATCH 018/428] terminal/new --- src/terminal/new/page.zig | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 0809796891..d4a7544ee9 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -58,6 +58,9 @@ pub const Page = struct { /// The available set of styles in use on this page. styles: style.Set, + /// The capacity of this page. + capacity: Capacity, + /// Capacity of this page. pub const Capacity = struct { /// Number of columns and rows we can know about. @@ -77,15 +80,12 @@ pub const Page = struct { errdefer alloc.free(backing); const buf = OffsetBuf.init(backing); - return .{ .memory = backing, .rows = buf.member(Row, l.rows_start), .cells = buf.member(Cell, l.cells_start), - .styles = style.Set.init( - buf.add(l.styles_start), - l.styles_layout, - ), + .styles = style.Set.init(buf.add(l.styles_start), l.styles_layout), + .capacity = cap, }; } @@ -94,7 +94,7 @@ pub const Page = struct { self.* = undefined; } - pub const Layout = struct { + const Layout = struct { total_size: usize, rows_start: usize, cells_start: usize, @@ -104,7 +104,7 @@ pub const Page = struct { /// The memory layout for a page given a desired minimum cols /// and rows size. - pub fn layout(cap: Capacity) Layout { + fn layout(cap: Capacity) Layout { const rows_start = 0; const rows_end = rows_start + (cap.rows * @sizeOf(Row)); @@ -128,9 +128,20 @@ pub const Page = struct { } }; -pub const Row = packed struct { +pub const Row = packed struct(u18) { /// The cells in the row offset from the page. cells: Offset(Cell), + + /// Flags where we want to pack bits + flags: packed struct { + /// True if this row is soft-wrapped. The first cell of the next + /// row is a continuation of this row. + wrap: bool = false, + + /// True if the previous row to this one is soft-wrapped and + /// this row is a continuation of that row. + wrap_continuation: bool = false, + }, }; /// A cell represents a single terminal grid cell. From 86deda520fdd266b8175a65163e1a630c6519505 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 20:03:03 -0800 Subject: [PATCH 019/428] terminal/new: initialize all rows to point to proper cell offsets --- src/terminal/new/page.zig | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index d4a7544ee9..273bfa19c6 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -5,6 +5,7 @@ const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const style = @import("style.zig"); const size = @import("size.zig"); +const getOffset = size.getOffset; const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; const hash_map = @import("hash_map.zig"); @@ -40,9 +41,6 @@ pub const Page = struct { /// The backing memory for the page. A page is always made up of a /// a single contiguous block of memory that is aligned on a page /// boundary and is a multiple of the system page size. - /// - /// The backing memory is always zero initialized, so the zero value - /// of all data within the page must always be valid. memory: []align(std.mem.page_size) u8, /// The array of rows in the page. The rows are always in row order @@ -78,12 +76,27 @@ pub const Page = struct { const l = layout(cap); const backing = try alloc.alignedAlloc(u8, std.mem.page_size, l.total_size); errdefer alloc.free(backing); + @memset(backing, 0); const buf = OffsetBuf.init(backing); + const rows = buf.member(Row, l.rows_start); + const cells = buf.member(Cell, l.cells_start); + + // We need to go through and initialize all the rows so that + // they point to a valid offset into the cells, since the rows + // zero-initialized aren't valid. + const cells_ptr = cells.ptr(buf)[0 .. cap.cols * cap.rows]; + for (rows.ptr(buf)[0..cap.rows], 0..) |*row, y| { + const start = y * cap.cols; + row.* = .{ + .cells = getOffset(Cell, buf, &cells_ptr[start]), + }; + } + return .{ .memory = backing, - .rows = buf.member(Row, l.rows_start), - .cells = buf.member(Cell, l.cells_start), + .rows = rows, + .cells = cells, .styles = style.Set.init(buf.add(l.styles_start), l.styles_layout), .capacity = cap, }; @@ -141,7 +154,7 @@ pub const Row = packed struct(u18) { /// True if the previous row to this one is soft-wrapped and /// this row is a continuation of that row. wrap_continuation: bool = false, - }, + } = .{}, }; /// A cell represents a single terminal grid cell. From 1216603e687165be835c91e02ad65090de4dd079 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 21:44:15 -0800 Subject: [PATCH 020/428] terminal/new: Screen beginnings --- src/terminal/main.zig | 1 + src/terminal/new/Screen.zig | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/terminal/new/Screen.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 0324556aa0..b238b41f74 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -53,6 +53,7 @@ test { _ = @import("new/hash_map.zig"); _ = @import("new/page.zig"); + _ = @import("new/Screen.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); } diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig new file mode 100644 index 0000000000..fbef50aef0 --- /dev/null +++ b/src/terminal/new/Screen.zig @@ -0,0 +1,113 @@ +const Screen = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const pagepkg = @import("page.zig"); +const Page = pagepkg.Page; + +// Some magic constants we use that could be tweaked... + +/// The number of PageList.Nodes we preheat the pool with. A node is +/// a very small struct so we can afford to preheat many, but the exact +/// number is uncertain. Any number too large is wasting memory, any number +/// too small will cause the pool to have to allocate more memory later. +/// This should be set to some reasonable minimum that we expect a terminal +/// window to scroll into quickly. +const page_preheat = 4; + +/// The default number of unique styles per page we expect. It is currently +/// "32" because anecdotally amongst a handful of beta testers, no one +/// under normal terminal use ever used more than 32 unique styles in a +/// single page. We found outliers but it was rare enough that we could +/// allocate those when necessary. +const page_default_styles = 32; + +/// The list of pages in the screen. These are expected to be in order +/// where the first page is the topmost page (scrollback) and the last is +/// the bottommost page (the current active page). +const PageList = std.DoublyLinkedList(Page); + +/// The memory pool we get page nodes from. +const PagePool = std.heap.MemoryPool(PageList.Node); + +/// The general purpose allocator to use for all memory allocations. +/// Unfortunately some screen operations do require allocation. +alloc: Allocator, + +/// The memory pool we get page nodes for the linked list from. +page_pool: PagePool, + +/// The list of pages in the screen. +pages: PageList, + +/// The page that contains the top of the current viewport and the row +/// within that page that is the top of the viewport (0-indexed). +viewport: *PageList.Node, +viewport_row: usize, + +/// The cursor position. +const Cursor = struct { + // The x/y position within the viewport. + x: usize = 0, + y: usize = 0, + + // The page that the cursor is on and the offset into that page that + // the current y exists. + page: *PageList.Node, + page_row: usize, +}; + +/// Initialize a new screen. +pub fn init( + alloc: Allocator, + cols: usize, + rows: usize, + max_scrollback: usize, +) !Screen { + _ = max_scrollback; + + // The screen starts with a single page that is the entire viewport, + // and we'll split it thereafter if it gets too large and add more as + // necessary. + var pool = try PagePool.initPreheated(alloc, page_preheat); + errdefer pool.deinit(); + + var page = try pool.create(); + // no errdefer because the pool deinit will clean up the page + + page.* = .{ + .data = try Page.init(alloc, .{ + .cols = cols, + .rows = rows, + .styles = page_default_styles, + }), + }; + errdefer page.data.deinit(alloc); + + var page_list: PageList = .{}; + page_list.prepend(page); + + return .{ + .alloc = alloc, + .page_pool = pool, + .pages = page_list, + .viewport = page, + .viewport_row = 0, + }; +} + +pub fn deinit(self: *Screen) void { + // Deallocate all the pages. We don't need to deallocate the list or + // nodes because they all reside in the pool. + while (self.pages.popFirst()) |node| node.data.deinit(self.alloc); + self.page_pool.deinit(); +} + +test { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); +} From 0a27e5a58b70ceebd4f58fb9c9ad569e5740f58a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Feb 2024 22:11:21 -0800 Subject: [PATCH 021/428] terminal/new: print some characters (test string) --- src/terminal/new/Screen.zig | 71 +++++++++++++++++++++++++++++++++++-- src/terminal/new/page.zig | 39 +++++++++++++++++++- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index fbef50aef0..fe5cd660c0 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -3,6 +3,7 @@ const Screen = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const unicode = @import("../../unicode/main.zig"); const pagepkg = @import("page.zig"); const Page = pagepkg.Page; @@ -46,16 +47,31 @@ pages: PageList, viewport: *PageList.Node, viewport_row: usize, +/// The current cursor position +cursor: Cursor, + +/// The current desired screen dimensions. I say "desired" because individual +/// pages may still be a different size and not yet reflowed since we lazily +/// reflow text. +cols: usize, +rows: usize, + /// The cursor position. const Cursor = struct { // The x/y position within the viewport. - x: usize = 0, - y: usize = 0, + x: usize, + y: usize, + + /// 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 page that the cursor is on and the offset into that page that // the current y exists. page: *PageList.Node, page_row: usize, + page_row_ptr: *pagepkg.Row, + page_cell_ptr: *pagepkg.Cell, }; /// Initialize a new screen. @@ -88,12 +104,27 @@ pub fn init( var page_list: PageList = .{}; page_list.prepend(page); + const cursor_row_ptr, const cursor_cell_ptr = ptr: { + const rac = page.data.getRowAndCell(0, 0); + break :ptr .{ rac.row, rac.cell }; + }; + return .{ .alloc = alloc, + .cols = cols, + .rows = rows, .page_pool = pool, .pages = page_list, .viewport = page, .viewport_row = 0, + .cursor = .{ + .x = 0, + .y = 0, + .page = page, + .page_row = 0, + .page_row_ptr = cursor_row_ptr, + .page_cell_ptr = cursor_cell_ptr, + }, }; } @@ -104,10 +135,44 @@ pub fn deinit(self: *Screen) void { self.page_pool.deinit(); } -test { +fn testWriteString(self: *Screen, text: []const u8) !void { + const view = try std.unicode.Utf8View.init(text); + var iter = view.iterator(); + while (iter.nextCodepoint()) |c| { + if (self.cursor.x == self.cols) { + @panic("wrap not implemented"); + } + + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + if (width == 0) { + @panic("zero-width todo"); + } + + assert(width == 1 or width == 2); + switch (width) { + 1 => { + self.cursor.page_cell_ptr.codepoint = c; + self.cursor.x += 1; + if (self.cursor.x < self.cols) { + const cell_ptr: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell_ptr); + self.cursor.page_cell_ptr = @ptrCast(cell_ptr + 1); + } else { + @panic("wrap not implemented"); + } + }, + + 2 => @panic("todo double-width"), + else => unreachable, + } + } +} + +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 s.testWriteString("hello, world"); } diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 273bfa19c6..ad9d49326e 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -107,6 +107,21 @@ pub const Page = struct { self.* = undefined; } + /// Get the row and cell for the given X/Y within this page. + pub fn getRowAndCell(self: *const Page, x: usize, y: usize) struct { + row: *Row, + cell: *Cell, + } { + assert(y < self.capacity.rows); + assert(x < self.capacity.cols); + + const rows = self.rows.ptr(self.memory); + const row = &rows[y]; + const cell = &row.cells.ptr(self.memory)[x]; + + return .{ .row = row, .cell = cell }; + } + const Layout = struct { total_size: usize, rows_start: usize, @@ -184,7 +199,7 @@ pub const Cell = packed struct(u32) { // }); // } -test "Page" { +test "Page init" { const testing = std.testing; const alloc = testing.allocator; var page = try Page.init(alloc, .{ @@ -194,3 +209,25 @@ test "Page" { }); defer page.deinit(alloc); } + +test "Page read and write cells" { + const testing = std.testing; + const alloc = testing.allocator; + var page = try Page.init(alloc, .{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(alloc); + + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.codepoint = @intCast(y); + } + + // Read it again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.codepoint); + } +} From 1473b3edf223c1731b3eef77ceab76e8b13807b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 09:34:42 -0800 Subject: [PATCH 022/428] terminal/new: PageList --- src/terminal/main.zig | 1 + src/terminal/new/PageList.zig | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/terminal/new/PageList.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index b238b41f74..56ea71e639 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -53,6 +53,7 @@ test { _ = @import("new/hash_map.zig"); _ = @import("new/page.zig"); + _ = @import("new/PageList.zig"); _ = @import("new/Screen.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig new file mode 100644 index 0000000000..2d684acb23 --- /dev/null +++ b/src/terminal/new/PageList.zig @@ -0,0 +1,108 @@ +//! Maintains a linked list of pages to make up a terminal screen +//! and provides higher level operations on top of those pages to +//! make it slightly easier to work with. +const PageList = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const pagepkg = @import("page.zig"); +const Page = pagepkg.Page; + +/// The number of PageList.Nodes we preheat the pool with. A node is +/// a very small struct so we can afford to preheat many, but the exact +/// number is uncertain. Any number too large is wasting memory, any number +/// too small will cause the pool to have to allocate more memory later. +/// This should be set to some reasonable minimum that we expect a terminal +/// window to scroll into quickly. +const page_preheat = 4; + +/// The default number of unique styles per page we expect. It is currently +/// "32" because anecdotally amongst a handful of beta testers, no one +/// under normal terminal use ever used more than 32 unique styles in a +/// single page. We found outliers but it was rare enough that we could +/// allocate those when necessary. +const page_default_styles = 32; + +/// The list of pages in the screen. These are expected to be in order +/// where the first page is the topmost page (scrollback) and the last is +/// the bottommost page (the current active page). +const List = std.DoublyLinkedList(Page); + +/// The memory pool we get page nodes from. +const Pool = std.heap.MemoryPool(List.Node); + +/// The allocator to use for pages. +alloc: Allocator, + +/// The memory pool we get page nodes for the linked list from. +pool: Pool, + +/// The list of pages in the screen. +pages: List, + +/// The page that contains the top of the current viewport and the row +/// within that page that is the top of the viewport (0-indexed). +viewport: *List.Node, +viewport_row: usize, + +/// The current desired screen dimensions. I say "desired" because individual +/// pages may still be a different size and not yet reflowed since we lazily +/// reflow text. +cols: usize, +rows: usize, + +pub fn init( + alloc: Allocator, + cols: usize, + rows: usize, + max_scrollback: usize, +) !PageList { + _ = max_scrollback; + + // The screen starts with a single page that is the entire viewport, + // and we'll split it thereafter if it gets too large and add more as + // necessary. + var pool = try Pool.initPreheated(alloc, page_preheat); + errdefer pool.deinit(); + + var page = try pool.create(); + // no errdefer because the pool deinit will clean up the page + + page.* = .{ + .data = try Page.init(alloc, .{ + .cols = cols, + .rows = rows, + .styles = page_default_styles, + }), + }; + errdefer page.data.deinit(alloc); + + var page_list: List = .{}; + page_list.prepend(page); + + return .{ + .alloc = alloc, + .cols = cols, + .rows = rows, + .pool = pool, + .pages = page_list, + .viewport = page, + .viewport_row = 0, + }; +} + +pub fn deinit(self: *PageList) void { + // Deallocate all the pages. We don't need to deallocate the list or + // nodes because they all reside in the pool. + while (self.pages.popFirst()) |node| node.data.deinit(self.alloc); + self.pool.deinit(); +} + +test "PageList" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); +} From b5d7b0a87ae747ae56779a9995f84d3fc8d149f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 19:50:42 -0800 Subject: [PATCH 023/428] terminal/new: lots of code thrown at the wall --- src/terminal/main.zig | 1 + src/terminal/new/PageList.zig | 137 ++++++++++++++++++++++++++-- src/terminal/new/Screen.zig | 162 +++++++++++++++------------------- src/terminal/new/page.zig | 19 ++++ src/terminal/new/point.zig | 71 +++++++++++++++ 5 files changed, 294 insertions(+), 96 deletions(-) create mode 100644 src/terminal/new/point.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 56ea71e639..79e424b93a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -55,6 +55,7 @@ test { _ = @import("new/page.zig"); _ = @import("new/PageList.zig"); _ = @import("new/Screen.zig"); + _ = @import("new/point.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); } diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 2d684acb23..92b7f4c5eb 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -6,6 +6,7 @@ const PageList = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const point = @import("point.zig"); const pagepkg = @import("page.zig"); const Page = pagepkg.Page; @@ -41,10 +42,15 @@ pool: Pool, /// The list of pages in the screen. pages: List, -/// The page that contains the top of the current viewport and the row -/// within that page that is the top of the viewport (0-indexed). -viewport: *List.Node, -viewport_row: usize, +/// The top-left of certain parts of the screen that are frequently +/// accessed so we don't have to traverse the linked list to find them. +/// +/// For other tags, don't need this: +/// - screen: pages.first +/// - history: active row minus one +/// +viewport: RowOffset, +active: RowOffset, /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily @@ -87,8 +93,8 @@ pub fn init( .rows = rows, .pool = pool, .pages = page_list, - .viewport = page, - .viewport_row = 0, + .viewport = .{ .page = page }, + .active = .{ .page = page }, }; } @@ -99,6 +105,125 @@ pub fn deinit(self: *PageList) void { self.pool.deinit(); } +/// Get the top-left of the screen for the given tag. +pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { + // TODO: assert the point is valid + + // This should never return null because we assert the point is valid. + return (switch (pt) { + .active => |v| self.active.forward(v.y), + .viewport => |v| self.viewport.forward(v.y), + .screen, .history => |v| offset: { + const tl: RowOffset = .{ .page = self.pages.first.? }; + break :offset tl.forward(v.y); + }, + }).?; +} + +/// Get the cell at the given point, or null if the cell does not +/// exist or is out of bounds. +pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { + const row = self.getTopLeft(pt).forward(pt.y) orelse return null; + const rac = row.page.data.getRowAndCell(row.row_offset, pt.x); + return .{ + .page = row.page, + .row = rac.row, + .cell = rac.cell, + .row_idx = row.row_offset, + .col_idx = pt.x, + }; +} + +pub const RowIterator = struct { + row: ?RowOffset = null, + limit: ?usize = null, + + pub fn next(self: *RowIterator) ?RowOffset { + const row = self.row orelse return null; + self.row = row.forward(1); + if (self.limit) |*limit| { + limit.* -= 1; + if (limit.* == 0) self.row = null; + } + + return row; + } +}; + +/// Create an interator that can be used to iterate all the rows in +/// a region of the screen from the given top-left. The tag of the +/// top-left point will also determine the end of the iteration, +/// so convert from one reference point to another to change the +/// iteration bounds. +pub fn rowIterator( + self: *const PageList, + tl_pt: point.Point, +) RowIterator { + const tl = self.getTopLeft(tl_pt); + + // TODO: limits + return .{ .row = tl.forward(tl_pt.coord().y) }; +} + +/// Get the top-left of the screen for the given tag. +fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { + return switch (tag) { + .active => self.active, + .viewport => self.viewport, + .screen, .history => .{ .page = self.pages.first.? }, + }; +} + +/// Represents some y coordinate within the screen. Since pages can +/// be split at any row boundary, getting some Y-coordinate within +/// any part of the screen may map to a different page and row offset +/// than the original y-coordinate. This struct represents that mapping. +pub const RowOffset = struct { + page: *List.Node, + row_offset: usize = 0, + + pub fn rowAndCell(self: RowOffset, x: usize) struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + } { + const rac = self.page.data.getRowAndCell(x, self.row_offset); + return .{ .row = rac.row, .cell = rac.cell }; + } + + /// Get the row at the given row index from this Topleft. This + /// may require traversing into the next page if the row index + /// is greater than the number of rows in this page. + /// + /// This will return null if the row index is out of bounds. + fn forward(self: RowOffset, idx: usize) ?RowOffset { + // Index fits within this page + var rows = self.page.data.capacity.rows - self.row_offset; + if (idx < rows) return .{ + .page = self.page, + .row_offset = idx + self.row_offset, + }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var idx_left: usize = idx; + while (idx_left >= rows) { + idx_left -= rows; + page = page.next orelse return null; + rows = page.data.capacity.rows; + } + + return .{ .page = page, .row_offset = idx_left }; + } +}; + +const Cell = struct { + page: *List.Node, + row: *pagepkg.Row, + cell: *pagepkg.Cell, + row_idx: usize, + col_idx: usize, +}; + test "PageList" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index fe5cd660c0..6d35653d37 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -4,58 +4,21 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const unicode = @import("../../unicode/main.zig"); +const PageList = @import("PageList.zig"); const pagepkg = @import("page.zig"); +const point = @import("point.zig"); const Page = pagepkg.Page; -// Some magic constants we use that could be tweaked... - -/// The number of PageList.Nodes we preheat the pool with. A node is -/// a very small struct so we can afford to preheat many, but the exact -/// number is uncertain. Any number too large is wasting memory, any number -/// too small will cause the pool to have to allocate more memory later. -/// This should be set to some reasonable minimum that we expect a terminal -/// window to scroll into quickly. -const page_preheat = 4; - -/// The default number of unique styles per page we expect. It is currently -/// "32" because anecdotally amongst a handful of beta testers, no one -/// under normal terminal use ever used more than 32 unique styles in a -/// single page. We found outliers but it was rare enough that we could -/// allocate those when necessary. -const page_default_styles = 32; - -/// The list of pages in the screen. These are expected to be in order -/// where the first page is the topmost page (scrollback) and the last is -/// the bottommost page (the current active page). -const PageList = std.DoublyLinkedList(Page); - -/// The memory pool we get page nodes from. -const PagePool = std.heap.MemoryPool(PageList.Node); - /// The general purpose allocator to use for all memory allocations. /// Unfortunately some screen operations do require allocation. alloc: Allocator, -/// The memory pool we get page nodes for the linked list from. -page_pool: PagePool, - /// The list of pages in the screen. pages: PageList, -/// The page that contains the top of the current viewport and the row -/// within that page that is the top of the viewport (0-indexed). -viewport: *PageList.Node, -viewport_row: usize, - /// The current cursor position cursor: Cursor, -/// The current desired screen dimensions. I say "desired" because individual -/// pages may still be a different size and not yet reflowed since we lazily -/// reflow text. -cols: usize, -rows: usize, - /// The cursor position. const Cursor = struct { // The x/y position within the viewport. @@ -66,12 +29,11 @@ const Cursor = struct { /// next character print will force a soft-wrap. pending_wrap: bool = false, - // The page that the cursor is on and the offset into that page that - // the current y exists. - page: *PageList.Node, - page_row: usize, - page_row_ptr: *pagepkg.Row, - page_cell_ptr: *pagepkg.Cell, + /// The pointers into the page list where the cursor is currently + /// located. This makes it faster to move the cursor. + page_offset: PageList.RowOffset, + page_row: *pagepkg.Row, + page_cell: *pagepkg.Cell, }; /// Initialize a new screen. @@ -81,65 +43,82 @@ pub fn init( rows: usize, max_scrollback: usize, ) !Screen { - _ = max_scrollback; - - // The screen starts with a single page that is the entire viewport, - // and we'll split it thereafter if it gets too large and add more as - // necessary. - var pool = try PagePool.initPreheated(alloc, page_preheat); - errdefer pool.deinit(); - - var page = try pool.create(); - // no errdefer because the pool deinit will clean up the page - - page.* = .{ - .data = try Page.init(alloc, .{ - .cols = cols, - .rows = rows, - .styles = page_default_styles, - }), - }; - errdefer page.data.deinit(alloc); + // Initialize our backing pages. This will initialize the viewport. + var pages = try PageList.init(alloc, cols, rows, max_scrollback); + errdefer pages.deinit(); - var page_list: PageList = .{}; - page_list.prepend(page); - - const cursor_row_ptr, const cursor_cell_ptr = ptr: { - const rac = page.data.getRowAndCell(0, 0); - break :ptr .{ rac.row, rac.cell }; - }; + // The viewport is guaranteed to exist, so grab it so we can setup + // our initial cursor. + const page_offset = pages.rowOffset(.{ .active = .{ .x = 0, .y = 0 } }); + const page_rac = page_offset.rowAndCell(0); return .{ .alloc = alloc, - .cols = cols, - .rows = rows, - .page_pool = pool, - .pages = page_list, - .viewport = page, - .viewport_row = 0, + .pages = pages, .cursor = .{ .x = 0, .y = 0, - .page = page, - .page_row = 0, - .page_row_ptr = cursor_row_ptr, - .page_cell_ptr = cursor_cell_ptr, + .page_offset = page_offset, + .page_row = page_rac.row, + .page_cell = page_rac.cell, }, }; } pub fn deinit(self: *Screen) void { - // Deallocate all the pages. We don't need to deallocate the list or - // nodes because they all reside in the pool. - while (self.pages.popFirst()) |node| node.data.deinit(self.alloc); - self.page_pool.deinit(); + self.pages.deinit(); +} + +/// 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(tl); + while (iter.next()) |row_offset| { + const rac = row_offset.rowAndCell(0); + const cells = cells: { + const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); + break :cells cells[0..self.pages.cols]; + }; + + if (blank_rows > 0) { + for (0..blank_rows) |_| try writer.writeByte('\n'); + blank_rows = 0; + } + + // TODO: handle wrap + blank_rows += 1; + + for (cells) |cell| { + // TODO: handle blanks between chars + if (cell.codepoint == 0) break; + try writer.print("{u}", .{cell.codepoint}); + } + } +} + +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(); } fn testWriteString(self: *Screen, text: []const u8) !void { const view = try std.unicode.Utf8View.init(text); var iter = view.iterator(); while (iter.nextCodepoint()) |c| { - if (self.cursor.x == self.cols) { + if (self.cursor.x == self.pages.cols) { @panic("wrap not implemented"); } @@ -151,11 +130,11 @@ fn testWriteString(self: *Screen, text: []const u8) !void { assert(width == 1 or width == 2); switch (width) { 1 => { - self.cursor.page_cell_ptr.codepoint = c; + self.cursor.page_cell.codepoint = c; self.cursor.x += 1; - if (self.cursor.x < self.cols) { - const cell_ptr: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell_ptr); - self.cursor.page_cell_ptr = @ptrCast(cell_ptr + 1); + if (self.cursor.x < self.pages.cols) { + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell + 1); } else { @panic("wrap not implemented"); } @@ -175,4 +154,7 @@ test "Screen read and write" { defer s.deinit(); try s.testWriteString("hello, world"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + //try testing.expectEqualStrings("hello, world", str); } diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index ad9d49326e..de88a820bc 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -107,6 +107,25 @@ pub const Page = struct { self.* = undefined; } + /// Get a single row. y must be valid. + pub fn getRow(self: *const Page, y: usize) *Row { + assert(y < self.capacity.rows); + return &self.rows.ptr(self.memory)[y]; + } + + /// Get the cells for a row. + pub fn getCells(self: *const Page, row: *Row) []Cell { + if (comptime std.debug.runtime_safety) { + const rows = self.rows.ptr(self.memory); + const cells = self.cells.ptr(self.memory); + assert(@intFromPtr(row) >= @intFromPtr(rows)); + assert(@intFromPtr(row) < @intFromPtr(cells)); + } + + const cells = row.cells.ptr(self.memory); + return cells[0..self.capacity.cols]; + } + /// Get the row and cell for the given X/Y within this page. pub fn getRowAndCell(self: *const Page, x: usize, y: usize) struct { row: *Row, diff --git a/src/terminal/new/point.zig b/src/terminal/new/point.zig new file mode 100644 index 0000000000..e0961e2015 --- /dev/null +++ b/src/terminal/new/point.zig @@ -0,0 +1,71 @@ +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, + }; + } +}; From 94c6573e540f3a3d5896314c134d34b8ac7f16df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 21:30:31 -0800 Subject: [PATCH 024/428] terminal/new: detect empty rows --- src/terminal/new/Screen.zig | 4 ++++ src/terminal/new/page.zig | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 6d35653d37..0ee165881e 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -87,6 +87,10 @@ pub fn dumpString( break :cells cells[0..self.pages.cols]; }; + if (!pagepkg.Cell.hasText(cells)) { + blank_rows += 1; + continue; + } if (blank_rows > 0) { for (0..blank_rows) |_| try writer.writeByte('\n'); blank_rows = 0; diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index de88a820bc..8d1bda3363 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -198,6 +198,15 @@ pub const Row = packed struct(u18) { pub const Cell = packed struct(u32) { codepoint: u21 = 0, _padding: u11 = 0, + + /// Returns true if the set of cells has text in it. + pub fn hasText(cells: []const Cell) bool { + for (cells) |cell| { + if (cell.codepoint != 0) return true; + } + + return false; + } }; // Uncomment this when you want to do some math. From a1c14d18590bb842d91f806bbcf09d4c9379ff78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 21:44:37 -0800 Subject: [PATCH 025/428] terminal/new: print single lines of ascii chars lol --- src/terminal/main.zig | 1 + src/terminal/new/Screen.zig | 12 +- src/terminal/new/Terminal.zig | 334 ++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/terminal/new/Terminal.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 79e424b93a..481b32090a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -55,6 +55,7 @@ test { _ = @import("new/page.zig"); _ = @import("new/PageList.zig"); _ = @import("new/Screen.zig"); + _ = @import("new/Terminal.zig"); _ = @import("new/point.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 0ee165881e..4675bfbcd5 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -69,6 +69,16 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } +/// 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) void { + assert(self.cursor.x + 1 < self.pages.cols); + + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell + 1); + self.cursor.x += 1; +} + /// 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. @@ -107,7 +117,7 @@ pub fn dumpString( } } -fn dumpStringAlloc( +pub fn dumpStringAlloc( self: *const Screen, alloc: Allocator, tl: point.Point, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig new file mode 100644 index 0000000000..3ae533112c --- /dev/null +++ b/src/terminal/new/Terminal.zig @@ -0,0 +1,334 @@ +//! 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(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const unicode = @import("../../unicode/main.zig"); + +const ansi = @import("../ansi.zig"); +const modes = @import("../modes.zig"); +const charsets = @import("../charsets.zig"); +const csi = @import("../csi.zig"); +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 pagepkg = @import("page.zig"); +const Screen = @import("Screen.zig"); +const Cell = pagepkg.Cell; +const Row = pagepkg.Row; + +const log = std.log.scoped(.terminal); + +/// Default tabstop interval +const TABSTOP_INTERVAL = 8; + +/// Screen type is an enum that tracks whether a screen is primary or alternate. +pub const ScreenType = enum { + primary, + 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. +active_screen: ScreenType, +screen: Screen, +secondary_screen: Screen, + +/// Whether we're currently writing to the status line (DECSASD and DECSSDT). +/// We don't support a status line currently so we just black hole this +/// data so that it doesn't mess up our main display. +status_display: ansi.StatusDisplay = .main, + +/// Where the tabstops are. +tabstops: Tabstops, + +/// The size of the terminal. +rows: usize, +cols: usize, + +/// The size of the screen in pixels. This is used for pty events and images +width_px: u32 = 0, +height_px: u32 = 0, + +/// The current scrolling region. +scrolling_region: ScrollingRegion, + +/// The last reported pwd, if any. +pwd: std.ArrayList(u8), + +/// The default color palette. This is only modified by changing the config file +/// and is used to reset the palette when receiving an OSC 104 command. +default_palette: color.Palette = color.default, + +/// The color palette to use. The mask indicates which palette indices have been +/// modified with OSC 4 +color_palette: struct { + const Mask = std.StaticBitSet(@typeInfo(color.Palette).Array.len); + colors: color.Palette = color.default, + mask: Mask = Mask.initEmpty(), +} = .{}, + +/// The previous printed character. This is used for the repeat previous +/// char CSI (ESC [ b). +previous_char: ?u21 = null, + +/// The modes that this terminal currently has active. +modes: modes.ModeState = .{}, + +/// The most recently set mouse shape for the terminal. +mouse_shape: mouse_shape.MouseShape = .text, + +/// These are just a packed set of flags we may set on the terminal. +flags: packed struct { + // This isn't a mode, this is set by OSC 133 using the "A" event. + // If this is true, it tells us that the shell supports redrawing + // the prompt and that when we resize, if the cursor is at a prompt, + // then we should clear the screen below and allow the shell to redraw. + shell_redraws_prompt: bool = false, + + // This is set via ESC[4;2m. Any other modify key mode just sets + // this to false and we act in mode 1 by default. + modify_other_keys_2: bool = false, + + /// The mouse event mode and format. These are set to the last + /// set mode in modes. You can't get the right event/format to use + /// based on modes alone because modes don't show you what order + /// this was called so we have to track it separately. + mouse_event: MouseEvents = .none, + mouse_format: MouseFormat = .x10, + + /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) + /// then we want to capture the shift key for the mouse protocol + /// if the configuration allows it. + mouse_shift_capture: enum { null, false, true } = .null, +} = .{}, + +/// The event types that can be reported for mouse-related activities. +/// These are all mutually exclusive (hence in a single enum). +pub const MouseEvents = enum(u3) { + none = 0, + x10 = 1, // 9 + normal = 2, // 1000 + button = 3, // 1002 + any = 4, // 1003 + + /// Returns true if this event sends motion events. + pub fn motion(self: MouseEvents) bool { + return self == .button or self == .any; + } +}; + +/// The format of mouse events when enabled. +/// These are all mutually exclusive (hence in a single enum). +pub const MouseFormat = enum(u3) { + x10 = 0, + utf8 = 1, // 1005 + sgr = 2, // 1006 + urxvt = 3, // 1015 + sgr_pixels = 4, // 1016 +}; + +/// Scrolling region is the area of the screen designated where scrolling +/// occurs. When scrolling the screen, only this viewport is scrolled. +pub const ScrollingRegion = struct { + // Top and bottom of the scroll region (0-indexed) + // Precondition: top < bottom + top: usize, + bottom: usize, + + // Left/right scroll regions. + // Precondition: right > left + // Precondition: right <= cols - 1 + left: usize, + right: usize, +}; + +/// Initialize a new 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, rows, cols, 10000), + // No scrollback for the alternate screen + .secondary_screen = try Screen.init(alloc, rows, cols, 0), + .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), + .scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }, + .pwd = std.ArrayList(u8).init(alloc), + }; +} + +pub fn deinit(self: *Terminal, alloc: Allocator) void { + self.tabstops.deinit(alloc); + self.screen.deinit(); + self.secondary_screen.deinit(); + self.pwd.deinit(); + self.* = undefined; +} + +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) { + @panic("TODO: graphemes"); + } + + // 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) { + @panic("TODO: zero-width characters"); + } + + // 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)) { + @panic("TODO: wraparound"); + } + + // 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) + { + @panic("TODO: insert mode"); + //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 => @panic("TODO: wide characters"), + + 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) { + self.screen.cursor.pending_wrap = true; + return; + } + + // Move the cursor + self.screen.cursorRight(); +} + +fn printCell(self: *Terminal, unmapped_c: u21) void { + // TODO: charsets + const c: u21 = unmapped_c; + + // 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 + self.screen.cursor.page_cell.* = .{ .codepoint = c }; + //cell.* = self.screen.cursor.pen; + //cell.char = @intCast(c); +} + +/// 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 = .{} }); +} + +test "Terminal: input with no control characters" { + const testing = std.testing; + 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(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello", str); + } +} From dc6de51472dc118f95f0e36fe63e338b0e7d5e2f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 21:56:07 -0800 Subject: [PATCH 026/428] terminal/new: add bench --- src/bench/stream.sh | 2 +- src/bench/stream.zig | 46 ++++++++++++++++++++++++++++------- src/terminal/main.zig | 9 +------ src/terminal/new/Terminal.zig | 2 +- src/terminal/new/main.zig | 16 ++++++++++++ 5 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 src/terminal/new/main.zig diff --git a/src/bench/stream.sh b/src/bench/stream.sh index 5f2e4d311b..38d4c37cda 100755 --- a/src/bench/stream.sh +++ b/src/bench/stream.sh @@ -8,7 +8,7 @@ # - "ascii", uniform random ASCII bytes # - "utf8", uniform random unicode characters, encoded as utf8 # - "rand", pure random data, will contain many invalid code sequences. -DATA="utf8" +DATA="ascii" SIZE="25000000" # Uncomment to test with an active terminal state. diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 5312a3d0ec..2deca30874 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -15,6 +15,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); const terminal = @import("../terminal/main.zig"); +const terminalnew = @import("../terminal/new/main.zig"); const Args = struct { mode: Mode = .noop, @@ -26,7 +27,7 @@ const Args = struct { /// Process input with a real terminal. This will be MUCH slower than /// the other modes because it has to maintain terminal state but will /// help get more realistic numbers. - terminal: bool = false, + terminal: Terminal = .none, @"terminal-rows": usize = 80, @"terminal-cols": usize = 120, @@ -42,6 +43,8 @@ const Args = struct { if (self._arena) |arena| arena.deinit(); self.* = undefined; } + + const Terminal = enum { none, old, new }; }; const Mode = enum { @@ -91,8 +94,7 @@ pub fn main() !void { const writer = std.io.getStdOut().writer(); const buf = try alloc.alloc(u8, args.@"buffer-size"); - const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) - else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); + const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); // Handle the modes that do not depend on terminal state first. switch (args.mode) { @@ -104,8 +106,8 @@ pub fn main() !void { // Handle the ones that depend on terminal state next inline .scalar, .simd, - => |tag| { - if (args.terminal) { + => |tag| switch (args.terminal) { + .old => { const TerminalStream = terminal.Stream(*TerminalHandler); var t = try terminal.Terminal.init( alloc, @@ -119,14 +121,32 @@ pub fn main() !void { .simd => try benchSimd(reader, &stream, buf), else => @compileError("missing case"), } - } else { + }, + + .new => { + const TerminalStream = terminal.Stream(*NewTerminalHandler); + var t = try terminalnew.Terminal.init( + alloc, + args.@"terminal-cols", + args.@"terminal-rows", + ); + var handler: NewTerminalHandler = .{ .t = &t }; + var stream: TerminalStream = .{ .handler = &handler }; + switch (tag) { + .scalar => try benchScalar(reader, &stream, buf), + .simd => try benchSimd(reader, &stream, buf), + else => @compileError("missing case"), + } + }, + + .none => { var stream: terminal.Stream(NoopHandler) = .{ .handler = .{} }; switch (tag) { .scalar => try benchScalar(reader, &stream, buf), .simd => try benchSimd(reader, &stream, buf), else => @compileError("missing case"), } - } + }, }, } } @@ -163,11 +183,11 @@ fn genUtf8(writer: anytype, seed: u64) !void { while (true) { var i: usize = 0; while (i <= buf.len - 4) { - const cp: u18 = while(true) { + const cp: u18 = while (true) { const cp = rnd.int(u18); if (ziglyph.isPrint(cp)) break cp; }; - + i += try std.unicode.utf8Encode(cp, buf[i..]); } @@ -244,3 +264,11 @@ const TerminalHandler = struct { try self.t.print(cp); } }; + +const NewTerminalHandler = struct { + t: *terminalnew.Terminal, + + pub fn print(self: *NewTerminalHandler, cp: u21) !void { + try self.t.print(cp); + } +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 481b32090a..45da8564e1 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -51,12 +51,5 @@ pub usingnamespace if (builtin.target.isWasm()) struct { test { @import("std").testing.refAllDecls(@This()); - _ = @import("new/hash_map.zig"); - _ = @import("new/page.zig"); - _ = @import("new/PageList.zig"); - _ = @import("new/Screen.zig"); - _ = @import("new/Terminal.zig"); - _ = @import("new/point.zig"); - _ = @import("new/size.zig"); - _ = @import("new/style.zig"); + _ = @import("new/main.zig"); } diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 3ae533112c..111723fb19 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -262,7 +262,7 @@ pub fn print(self: *Terminal, c: u21) !void { // 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) { - self.screen.cursor.pending_wrap = true; + //self.screen.cursor.pending_wrap = true; return; } diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig new file mode 100644 index 0000000000..cbff028192 --- /dev/null +++ b/src/terminal/new/main.zig @@ -0,0 +1,16 @@ +const builtin = @import("builtin"); + +pub const Terminal = @import("Terminal.zig"); + +test { + @import("std").testing.refAllDecls(@This()); + + // todo: make top-level imports + _ = @import("hash_map.zig"); + _ = @import("page.zig"); + _ = @import("PageList.zig"); + _ = @import("Screen.zig"); + _ = @import("point.zig"); + _ = @import("size.zig"); + _ = @import("style.zig"); +} From c44bc54dafb630869f893ed87187bef3b3cf9480 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Feb 2024 08:37:33 -0800 Subject: [PATCH 027/428] terminal/new: store full style id --- src/terminal/new/Screen.zig | 8 ++++++++ src/terminal/new/Terminal.zig | 13 ++++++++++--- src/terminal/new/page.zig | 9 ++++++--- src/terminal/new/size.zig | 4 ++-- src/terminal/new/style.zig | 8 ++++++++ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 4675bfbcd5..69680071b4 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -7,6 +7,8 @@ const unicode = @import("../../unicode/main.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; /// The general purpose allocator to use for all memory allocations. @@ -29,6 +31,12 @@ const Cursor = struct { /// next character print will force a soft-wrap. pending_wrap: bool = false, + /// The currently active style. 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_offset: PageList.RowOffset, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 111723fb19..a7cf7ecfdf 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -20,6 +20,7 @@ const color = @import("../color.zig"); const mouse_shape = @import("../mouse_shape.zig"); const pagepkg = @import("page.zig"); +const style = @import("style.zig"); const Screen = @import("Screen.zig"); const Cell = pagepkg.Cell; const Row = pagepkg.Row; @@ -303,9 +304,15 @@ fn printCell(self: *Terminal, unmapped_c: u21) void { //if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); // Write - self.screen.cursor.page_cell.* = .{ .codepoint = c }; - //cell.* = self.screen.cursor.pen; - //cell.char = @intCast(c); + self.screen.cursor.page_cell.* = .{ + .style_id = self.screen.cursor.style_id, + .codepoint = c, + }; + + // If we have non-default style then we need to update the ref count. + if (self.screen.cursor.style_ref) |ref| { + ref.* += 1; + } } /// Return the current string value of the terminal. Newlines are diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 8d1bda3363..4f6be3a8f3 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -175,7 +175,9 @@ pub const Page = struct { } }; -pub const Row = packed struct(u18) { +pub const Row = packed struct(u64) { + _padding: u30 = 0, + /// The cells in the row offset from the page. cells: Offset(Cell), @@ -195,9 +197,10 @@ pub const Row = packed struct(u18) { /// /// The zero value of this struct must be a valid cell representing empty, /// since we zero initialize the backing memory for a page. -pub const Cell = packed struct(u32) { +pub const Cell = packed struct(u64) { + style_id: style.Id = 0, codepoint: u21 = 0, - _padding: u11 = 0, + _padding: u27 = 0, /// Returns true if the set of cells has text in it. pub fn hasText(cells: []const Cell) bool { diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 092dcd72da..977fe54d2b 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -5,7 +5,7 @@ const assert = std.debug.assert; /// smaller bit size by Zig is upgraded anyways to a u16 on mainstream /// CPU architectures, and because 65KB is a reasonable page size. To /// support better configurability, we derive everything from this. -pub const max_page_size = 65_536; +pub const max_page_size = std.math.maxInt(u32); /// The int type that can contain the maximum memory offset in bytes, /// derived from the maximum terminal page size. @@ -138,7 +138,7 @@ test "Offset" { // This test is here so that if Offset changes, we can be very aware // of this effect and think about the implications of it. const testing = std.testing; - try testing.expect(OffsetInt == u16); + try testing.expect(OffsetInt == u32); } test "Offset ptr u8" { diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 668e9f7c8e..3de5816761 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); +const page = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; @@ -12,6 +13,9 @@ const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; /// that can fit into a terminal page. pub const Id = size.CellCountInt; +/// The Id to use for default styling. +pub const default_id: Id = 0; + /// The style attributes for a cell. pub const Style = struct { /// Various colors, all self-explanatory. @@ -82,6 +86,10 @@ pub const Set = struct { /// When this overflows we'll begin returning an IdOverflow /// error and the caller must manually compact the style /// set. + /// + /// Id zero is reserved and always is the default style. The + /// default style isn't present in the map, its dependent on + /// the terminal configuration. next_id: Id = 1, /// Maps a style definition to metadata about that style. From f6b202f24af4b796df2ce08cb7bd7de2b7a31d34 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Feb 2024 10:12:09 -0800 Subject: [PATCH 028/428] terminal/new: todos --- src/terminal/new/Terminal.zig | 2 ++ src/terminal/new/page.zig | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index a7cf7ecfdf..089981cf94 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -303,6 +303,8 @@ fn printCell(self: *Terminal, unmapped_c: u21) void { // If the prior value had graphemes, clear those //if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); + // TODO: prev cell overwriting style + // Write self.screen.cursor.page_cell.* = .{ .style_id = self.screen.cursor.style_id, diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 4f6be3a8f3..8628d0ea91 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -53,6 +53,12 @@ pub const Page = struct { /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + /// The multi-codepoint grapheme data for this page. This is where + /// any cell that has more than one codepoint will be stored. This is + /// relatively rare (typically only emoji) so this defaults to a very small + /// size and we force page realloc when it grows. + __todo_graphemes: void = {}, + /// The available set of styles in use on this page. styles: style.Set, From ed6a31a6926acb5239c1a49b2666d1068682272d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 09:54:27 -0800 Subject: [PATCH 029/428] terminal/new: add BitmapAllocator --- src/terminal/new/bitmap_allocator.zig | 323 ++++++++++++++++++++++++++ src/terminal/new/main.zig | 1 + 2 files changed, 324 insertions(+) create mode 100644 src/terminal/new/bitmap_allocator.zig diff --git a/src/terminal/new/bitmap_allocator.zig b/src/terminal/new/bitmap_allocator.zig new file mode 100644 index 0000000000..bfd341ed66 --- /dev/null +++ b/src/terminal/new/bitmap_allocator.zig @@ -0,0 +1,323 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const size = @import("size.zig"); +const getOffset = size.getOffset; +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; +const alignForward = std.mem.alignForward; + +/// A relatively naive bitmap allocator that uses memory offsets against +/// a fixed backing buffer so that the backing buffer can be easily moved +/// without having to update pointers. +/// +/// The chunk size determines the size of each chunk in bytes. This is the +/// minimum distributed unit of memory. For example, if you request a +/// 1-byte allocation, you'll use a chunk of chunk_size bytes. Likewise, +/// if your chunk size is 4, and you request a 5-byte allocation, you'll +/// use 2 chunks. +/// +/// The allocator is susceptible to fragmentation. If you allocate and free +/// memory in a way that leaves small holes in the memory, you may not be +/// able to allocate large chunks of memory even if there is enough free +/// memory in aggregate. To avoid fragmentation, use a chunk size that is +/// large enough to cover most of your allocations. +/// +// Notes for contributors: this is highly contributor friendly part of +// the code. If you can improve this, add tests, show benchmarks, then +// please do so! +pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { + return struct { + const Self = @This(); + + comptime { + assert(std.math.isPowerOfTwo(chunk_size)); + } + + pub const base_align = @alignOf(u64); + pub const bitmap_bit_size = @bitSizeOf(u64); + + /// The bitmap of available chunks. Each bit represents a chunk. A + /// 1 means the chunk is free and a 0 means it's used. We use 1 + /// for free since it makes it very slightly faster to find free + /// chunks. + bitmap: Offset(u64), + bitmap_count: usize, + + /// The contiguous buffer of chunks. + chunks: Offset(u8), + + /// Initialize the allocator map with a given buf and memory layout. + pub fn init(buf: OffsetBuf, l: Layout) Self { + assert(@intFromPtr(buf.start()) % base_align == 0); + + // Initialize our bitmaps to all 1s to note that all chunks are free. + const bitmap = buf.member(u64, l.bitmap_start); + const bitmap_ptr = bitmap.ptr(buf); + @memset(bitmap_ptr[0..l.bitmap_count], std.math.maxInt(u64)); + + return .{ + .bitmap = bitmap, + .bitmap_count = l.bitmap_count, + .chunks = buf.member(u8, l.chunks_start), + }; + } + + /// Allocate n elements of type T. This will return error.OutOfMemory + /// if there isn't enough space in the backing buffer. + pub fn alloc( + self: *Self, + comptime T: type, + base: anytype, + n: usize, + ) Allocator.Error![]T { + // note: we don't handle alignment yet, we just require that all + // types are properly aligned. This is a limitation that should be + // fixed but we haven't needed it. Contributor friendly: add tests + // and fix this. + assert(chunk_size % @alignOf(T) == 0); + + const byte_count = std.math.mul(usize, @sizeOf(T), n) catch + return error.OutOfMemory; + const chunk_count = std.math.divCeil(usize, byte_count, chunk_size) catch + return error.OutOfMemory; + + // Find the index of the free chunk. This also marks it as used. + const bitmaps = self.bitmap.ptr(base); + const idx = findFreeChunks(bitmaps[0..self.bitmap_count], chunk_count) orelse + return error.OutOfMemory; + + const chunks = self.chunks.ptr(base); + const ptr: [*]T = @alignCast(@ptrCast(&chunks[idx * chunk_size])); + return ptr[0..n]; + } + + pub fn free(self: *Self, base: anytype, slice: anytype) void { + // Convert the slice of whatever type to a slice of bytes. We + // can then use the byte len and chunk size to determine the + // number of chunks that were allocated. + const bytes = std.mem.sliceAsBytes(slice); + const aligned_len = std.mem.alignForward(usize, bytes.len, chunk_size); + const chunk_count = @divExact(aligned_len, chunk_size); + + // From the pointer, we can calculate the exact index. + const chunks = self.chunks.ptr(base); + const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size); + + // From the chunk index, we can find the bitmap index + const bitmap_idx = @divFloor(chunk_idx, 64); + const bitmap_bit = chunk_idx % 64; + + // Set the bitmap to mark the chunks as free + const bitmaps = self.bitmap.ptr(base); + const bitmap = &bitmaps[bitmap_idx]; + for (0..chunk_count) |i| { + const mask = @as(u64, 1) << @intCast(bitmap_bit + i); + bitmap.* |= mask; + } + } + + /// For debugging + fn dumpBitmaps(self: *Self, base: anytype) void { + const bitmaps = self.bitmap.ptr(base); + for (bitmaps[0..self.bitmap_count], 0..) |bitmap, idx| { + std.log.warn("bm={b} idx={}", .{ bitmap, idx }); + } + } + + const Layout = struct { + total_size: usize, + bitmap_count: usize, + bitmap_start: usize, + chunks_start: usize, + }; + + /// Get the layout for the given capacity. The capacity is in + /// number of bytes, not chunks. The capacity will likely be + /// rounded up to the nearest chunk size and bitmap size so + /// everything is perfectly divisible. + pub fn layout(cap: usize) Layout { + // Align the cap forward to our chunk size so we always have + // a full chunk at the end. + const aligned_cap = alignForward(usize, cap, chunk_size); + + // Calculate the number of bitmaps. We need 1 bitmap per 64 chunks. + // We align the chunk count forward so our bitmaps are full so we + // don't have to handle the case where we have a partial bitmap. + const chunk_count = @divExact(aligned_cap, chunk_size); + const aligned_chunk_count = alignForward(usize, chunk_count, 64); + const bitmap_count = @divExact(aligned_chunk_count, 64); + + const bitmap_start = 0; + const bitmap_end = @sizeOf(u64) * bitmap_count; + const chunks_start = alignForward(usize, bitmap_end, @alignOf(u8)); + const chunks_end = chunks_start + (aligned_cap * chunk_size); + const total_size = chunks_end; + + return Layout{ + .total_size = total_size, + .bitmap_count = bitmap_count, + .bitmap_start = bitmap_start, + .chunks_start = chunks_start, + }; + } + }; +} + +/// Find `n` sequential free chunks in the given bitmaps and return the index +/// of the first chunk. If no chunks are found, return `null`. This also updates +/// the bitmap to mark the chunks as used. +fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { + // NOTE: This is a naive implementation that just iterates through the + // bitmaps. There is very likely a more efficient way to do this but + // I'm not a bit twiddling expert. Perhaps even SIMD could be used here + // but unsure. Contributor friendly: let's benchmark and improve this! + + // TODO: handle large chunks + assert(n < @bitSizeOf(u64)); + + for (bitmaps, 0..) |*bitmap, idx| { + // Shift the bitmap to find `n` sequential free chunks. + var shifted: u64 = bitmap.*; + for (1..n) |i| shifted &= bitmap.* >> @intCast(i); + + // If we have zero then we have no matches + if (shifted == 0) continue; + + // Trailing zeroes gets us the bit 1-indexed + const bit = @ctz(shifted); + + // Calculate the mask so we can mark it as used + for (0..n) |i| { + const mask = @as(u64, 1) << @intCast(bit + i); + bitmap.* ^= mask; + } + + return (idx * 63) + bit; + } + + return null; +} + +test "findFreeChunks single found" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b10000000_00000000_00000000_00000000_00000000_00000000_00001110_00000000, + }; + const idx = findFreeChunks(&bitmaps, 2).?; + try testing.expectEqual(@as(usize, 9), idx); + try testing.expectEqual( + 0b10000000_00000000_00000000_00000000_00000000_00000000_00001000_00000000, + bitmaps[0], + ); +} + +test "findFreeChunks single not found" { + const testing = std.testing; + + var bitmaps = [_]u64{0b10000111_00000000_00000000_00000000_00000000_00000000_00000000_00000000}; + const idx = findFreeChunks(&bitmaps, 4); + try testing.expect(idx == null); +} + +test "findFreeChunks multiple found" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b10000111_00000000_00000000_00000000_00000000_00000000_00000000_01110000, + 0b10000000_00111110_00000000_00000000_00000000_00000000_00111110_00000000, + }; + const idx = findFreeChunks(&bitmaps, 4).?; + try testing.expectEqual(@as(usize, 72), idx); + try testing.expectEqual( + 0b10000000_00111110_00000000_00000000_00000000_00000000_00100000_00000000, + bitmaps[1], + ); +} + +test "BitmapAllocator layout" { + const Alloc = BitmapAllocator(4); + const cap = 64 * 4; + + const testing = std.testing; + const layout = Alloc.layout(cap); + + // We expect to use one bitmap since the cap is bytes. + try testing.expectEqual(@as(usize, 1), layout.bitmap_count); +} + +test "BitmapAllocator alloc sequentially" { + const Alloc = BitmapAllocator(4); + const cap = 64; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u8, buf, 1); + ptr[0] = 'A'; + + const ptr2 = try bm.alloc(u8, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + + // Should grab the next chunk + try testing.expectEqual(@intFromPtr(ptr.ptr) + 4, @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u8, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} + +test "BitmapAllocator alloc non-byte" { + const Alloc = BitmapAllocator(4); + const cap = 128; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u21, buf, 1); + ptr[0] = 'A'; + + const ptr2 = try bm.alloc(u21, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + try testing.expectEqual(@intFromPtr(ptr.ptr) + 4, @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u21, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} + +test "BitmapAllocator alloc non-byte multi-chunk" { + const Alloc = BitmapAllocator(4 * @sizeOf(u21)); + const cap = 128; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u21, buf, 6); + try testing.expectEqual(@as(usize, 6), ptr.len); + for (ptr) |*v| v.* = 'A'; + + const ptr2 = try bm.alloc(u21, buf, 1); + try testing.expect(@intFromPtr(ptr.ptr) != @intFromPtr(ptr2.ptr)); + try testing.expectEqual(@intFromPtr(ptr.ptr) + (@sizeOf(u21) * 4 * 2), @intFromPtr(ptr2.ptr)); + + // Free ptr and next allocation should be back + bm.free(buf, ptr); + const ptr3 = try bm.alloc(u21, buf, 1); + try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); +} diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index cbff028192..93ea0e04e6 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -6,6 +6,7 @@ test { @import("std").testing.refAllDecls(@This()); // todo: make top-level imports + _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); _ = @import("page.zig"); _ = @import("PageList.zig"); From f8f9f74a8ec0d41a8e11da40d5f42e8abb4d7f12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 10:33:50 -0800 Subject: [PATCH 030/428] terminal/new: page has graphemes attached --- src/terminal/new/bitmap_allocator.zig | 2 +- src/terminal/new/page.zig | 53 +++++++++++++++++++++++++-- src/terminal/new/size.zig | 6 +++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/terminal/new/bitmap_allocator.zig b/src/terminal/new/bitmap_allocator.zig index bfd341ed66..242575f9f5 100644 --- a/src/terminal/new/bitmap_allocator.zig +++ b/src/terminal/new/bitmap_allocator.zig @@ -125,7 +125,7 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { } } - const Layout = struct { + pub const Layout = struct { total_size: usize, bitmap_count: usize, bitmap_start: usize, diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 8628d0ea91..b5e6bcb3a9 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -8,6 +8,7 @@ const size = @import("size.zig"); const getOffset = size.getOffset; const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; +const BitmapAllocator = @import("bitmap_allocator.zig").BitmapAllocator; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const alignForward = std.mem.alignForward; @@ -57,7 +58,8 @@ pub const Page = struct { /// any cell that has more than one codepoint will be stored. This is /// relatively rare (typically only emoji) so this defaults to a very small /// size and we force page realloc when it grows. - __todo_graphemes: void = {}, + grapheme_alloc: GraphemeAlloc, + grapheme_map: GraphemeMap, /// The available set of styles in use on this page. styles: style.Set, @@ -65,6 +67,18 @@ pub const Page = struct { /// The capacity of this page. capacity: Capacity, + /// The allocator to use for multi-codepoint grapheme data. We use + /// a chunk size of 4 codepoints. It'd be best to set this empirically + /// but it is currently set based on vibes. My thinking around 4 codepoints + /// is that most skin-tone emoji are <= 4 codepoints, letter combiners + /// are usually <= 4 codepoints, and 4 codepoints is a nice power of two + /// for alignment. + const grapheme_chunk = 4 * @sizeOf(u21); + const GraphemeAlloc = BitmapAllocator(grapheme_chunk); + const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; + const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; + const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + /// Capacity of this page. pub const Capacity = struct { /// Number of columns and rows we can know about. @@ -72,7 +86,10 @@ pub const Page = struct { rows: usize, /// Number of unique styles that can be used on this page. - styles: u16, + styles: u16 = 16, + + /// Number of bytes to allocate for grapheme data. + grapheme_bytes: usize = grapheme_bytes_default, }; /// Initialize a new page, allocating the required backing memory. @@ -103,7 +120,18 @@ pub const Page = struct { .memory = backing, .rows = rows, .cells = cells, - .styles = style.Set.init(buf.add(l.styles_start), l.styles_layout), + .styles = style.Set.init( + buf.add(l.styles_start), + l.styles_layout, + ), + .grapheme_alloc = GraphemeAlloc.init( + buf.add(l.grapheme_alloc_start), + l.grapheme_alloc_layout, + ), + .grapheme_map = GraphemeMap.init( + buf.add(l.grapheme_map_start), + l.grapheme_map_layout, + ), .capacity = cap, }; } @@ -153,6 +181,10 @@ pub const Page = struct { cells_start: usize, styles_start: usize, styles_layout: style.Set.Layout, + grapheme_alloc_start: usize, + grapheme_alloc_layout: GraphemeAlloc.Layout, + grapheme_map_start: usize, + grapheme_map_layout: GraphemeMap.Layout, }; /// The memory layout for a page given a desired minimum cols @@ -169,7 +201,16 @@ pub const Page = struct { const styles_start = alignForward(usize, cells_end, style.Set.base_align); const styles_end = styles_start + styles_layout.total_size; - const total_size = styles_end; + const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); + const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align); + const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size; + + const grapheme_count = @divFloor(cap.grapheme_bytes, grapheme_chunk); + const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count)); + const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); + const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; + + const total_size = grapheme_map_end; return .{ .total_size = total_size, @@ -177,6 +218,10 @@ pub const Page = struct { .cells_start = cells_start, .styles_start = styles_start, .styles_layout = styles_layout, + .grapheme_alloc_start = grapheme_alloc_start, + .grapheme_alloc_layout = grapheme_alloc_layout, + .grapheme_map_start = grapheme_map_start, + .grapheme_map_layout = grapheme_map_layout, }; } }; diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index 977fe54d2b..b74316c8a9 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -24,6 +24,12 @@ pub fn Offset(comptime T: type) type { offset: OffsetInt = 0, + /// A slice of type T that stores via a base offset and len. + pub const Slice = struct { + offset: Self, + len: usize, + }; + /// Returns a pointer to the start of the data, properly typed. pub fn ptr(self: Self, base: anytype) [*]T { // The offset must be properly aligned for the type since From 05d7d978dd35721aa58601674506e5679eb265f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 10:42:52 -0800 Subject: [PATCH 031/428] terminal/new: page has grapheme metadata --- src/terminal/new/page.zig | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index b5e6bcb3a9..6ffc7f2d66 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -59,6 +59,12 @@ pub const Page = struct { /// relatively rare (typically only emoji) so this defaults to a very small /// size and we force page realloc when it grows. grapheme_alloc: GraphemeAlloc, + + /// The mapping of cell to grapheme data. The exact mapping is the + /// cell offset to the grapheme data offset. Therefore, whenever a + /// cell is moved (i.e. `erase`) then the grapheme data must be updated. + /// Grapheme data is relatively rare so this is considered a slow + /// path. grapheme_map: GraphemeMap, /// The available set of styles in use on this page. @@ -227,7 +233,7 @@ pub const Page = struct { }; pub const Row = packed struct(u64) { - _padding: u30 = 0, + _padding: u29 = 0, /// The cells in the row offset from the page. cells: Offset(Cell), @@ -241,6 +247,12 @@ pub const Row = packed struct(u64) { /// True if the previous row to this one is soft-wrapped and /// this row is a continuation of that row. wrap_continuation: bool = false, + + /// True if any of the cells in this row have multi-codepoint + /// grapheme clusters. If this is true, some fast paths are not + /// possible because erasing for example may need to clear existing + /// grapheme data. + grapheme: bool = false, } = .{}, }; @@ -249,9 +261,20 @@ pub const Row = packed struct(u64) { /// The zero value of this struct must be a valid cell representing empty, /// since we zero initialize the backing memory for a page. pub const Cell = packed struct(u64) { - style_id: style.Id = 0, + /// The codepoint that this cell contains. If `grapheme` is false, + /// then this is the only codepoint in the cell. If `grapheme` is + /// true, then this is the first codepoint in the grapheme cluster. codepoint: u21 = 0, - _padding: u27 = 0, + + /// The style ID to use for this cell within the style map. Zero + /// is always the default style so no lookup is required. + style_id: style.Id = 0, + + /// This is true if there are additional codepoints in the grapheme + /// map for this cell to build a multi-codepoint grapheme. + grapheme: bool = false, + + _padding: u26 = 0, /// Returns true if the set of cells has text in it. pub fn hasText(cells: []const Cell) bool { From 76f868621f648fc6b0ccb0293a09838aae91c192 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 10:46:03 -0800 Subject: [PATCH 032/428] terminal/new: handle zero-width at beginning of line --- src/terminal/new/Terminal.zig | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 089981cf94..3072d07087 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -6,6 +6,7 @@ const Terminal = @This(); const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; +const testing = std.testing; const Allocator = std.mem.Allocator; const unicode = @import("../../unicode/main.zig"); @@ -227,6 +228,20 @@ pub fn print(self: *Terminal, c: u21) !void { // 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; + } + @panic("TODO: zero-width characters"); } @@ -326,7 +341,6 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { } test "Terminal: input with no control characters" { - const testing = std.testing; const alloc = testing.allocator; var t = try init(alloc, 40, 40); defer t.deinit(alloc); @@ -341,3 +355,15 @@ test "Terminal: input with no control characters" { try testing.expectEqualStrings("hello", str); } } + +test "Terminal: zero-width character at start" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // This used to crash the terminal. This is not allowed so we should + // just ignore it. + try t.print(0x200D); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); +} From 01f2a9b39aca27248fe3e624fea56ea9a5941f53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 11:08:59 -0800 Subject: [PATCH 033/428] terminal/new: wraparound beginnings --- src/terminal/new/PageList.zig | 2 +- src/terminal/new/Screen.zig | 27 ++++++++ src/terminal/new/Terminal.zig | 112 +++++++++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 92b7f4c5eb..f2f3528fbc 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -195,7 +195,7 @@ pub const RowOffset = struct { /// is greater than the number of rows in this page. /// /// This will return null if the row index is out of bounds. - fn forward(self: RowOffset, idx: usize) ?RowOffset { + pub fn forward(self: RowOffset, idx: usize) ?RowOffset { // Index fits within this page var rows = self.page.data.capacity.rows - self.row_offset; if (idx < rows) return .{ diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 69680071b4..3d1f27ab73 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -87,6 +87,33 @@ pub fn cursorRight(self: *Screen) void { self.cursor.x += 1; } +/// Move the cursor down. +/// +/// Precondition: The cursor is not at the bottom of the screen. +pub fn cursorDown(self: *Screen) void { + assert(self.cursor.y + 1 < 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_offset = self.cursor.page_offset.forward(1).?; + const page_rac = page_offset.rowAndCell(0); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Y of course increases + self.cursor.y += 1; +} + +/// Move the cursor to some absolute position. +pub fn cursorHorizontalAbsolute(self: *Screen, x: usize) void { + assert(x < self.pages.cols); + + const page_rac = self.cursor.page_offset.rowAndCell(x); + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; +} + /// 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. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 3072d07087..449c77141b 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -250,7 +250,7 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're soft-wrapping, then handle that first. if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { - @panic("TODO: wraparound"); + try self.printWrap(); } // If we have insert mode enabled then we need to handle that. We @@ -277,8 +277,8 @@ pub fn print(self: *Terminal, c: u21) !void { // 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) { - //self.screen.cursor.pending_wrap = true; + if (self.screen.cursor.x == right_limit - 1) { + self.screen.cursor.pending_wrap = true; return; } @@ -332,6 +332,85 @@ fn printCell(self: *Terminal, unmapped_c: u21) void { } } +fn printWrap(self: *Terminal) !void { + self.screen.cursor.page_row.flags.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. + // TODO(mitchellh): before merge + //const old_prompt = row.getSemanticPrompt(); + + // Move to the next line + try self.index(); + self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); + + // TODO(mitchellh): before merge + // 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); + self.screen.cursor.page_row.flags.wrap_continuation = true; +} + +/// 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(); + } + + 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) + { + @panic("TODO: scroll screen"); + //try self.screen.scroll(.{ .screen = 1 }); + } else { + @panic("TODO: scroll up"); + //try 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(); + } +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -356,6 +435,23 @@ test "Terminal: input with no control characters" { } } +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: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -367,3 +463,13 @@ test "Terminal: zero-width character at start" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } + +// https://github.com/mitchellh/ghostty/issues/1400 +// 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'); +// } From 06e88a975b6eda1e88082f192950c408e62282ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 19:26:44 -0800 Subject: [PATCH 034/428] terminal/new: pages have a size --- src/terminal/new/PageList.zig | 9 +++++---- src/terminal/new/Screen.zig | 10 +++++----- src/terminal/new/Terminal.zig | 15 ++++++++------- src/terminal/new/page.zig | 32 ++++++++++++++++++++++---------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index f2f3528fbc..1f00751362 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -8,6 +8,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const point = @import("point.zig"); const pagepkg = @import("page.zig"); +const size = @import("size.zig"); const Page = pagepkg.Page; /// The number of PageList.Nodes we preheat the pool with. A node is @@ -55,13 +56,13 @@ active: RowOffset, /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily /// reflow text. -cols: usize, -rows: usize, +cols: size.CellCountInt, +rows: size.CellCountInt, pub fn init( alloc: Allocator, - cols: usize, - rows: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, max_scrollback: usize, ) !PageList { _ = max_scrollback; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 3d1f27ab73..926825e39f 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -24,8 +24,8 @@ cursor: Cursor, /// The cursor position. const Cursor = struct { // The x/y position within the viewport. - x: usize, - y: usize, + x: size.CellCountInt, + y: size.CellCountInt, /// The "last column flag (LCF)" as its called. If this is set then the /// next character print will force a soft-wrap. @@ -47,8 +47,8 @@ const Cursor = struct { /// Initialize a new screen. pub fn init( alloc: Allocator, - cols: usize, - rows: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, max_scrollback: usize, ) !Screen { // Initialize our backing pages. This will initialize the viewport. @@ -106,7 +106,7 @@ pub fn cursorDown(self: *Screen) void { } /// Move the cursor to some absolute position. -pub fn cursorHorizontalAbsolute(self: *Screen, x: usize) void { +pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { assert(x < self.pages.cols); const page_rac = self.cursor.page_offset.rowAndCell(x); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 449c77141b..96b81ae1bf 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -20,6 +20,7 @@ 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"); @@ -65,8 +66,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, @@ -155,18 +156,18 @@ 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, diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 6ffc7f2d66..7b42d72401 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -70,7 +70,13 @@ pub const Page = struct { /// The available set of styles in use on this page. styles: style.Set, - /// The capacity of this page. + /// The current dimensions of the page. The capacity may be larger + /// than this. This allows us to allocate a larger page than necessary + /// and also to resize a page smaller witout reallocating. + size: Size, + + /// The capacity of this page. This is the full size of the backing + /// memory and is fixed at page creation time. capacity: Capacity, /// The allocator to use for multi-codepoint grapheme data. We use @@ -85,11 +91,17 @@ pub const Page = struct { const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + /// The size of this page. + pub const Size = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + }; + /// Capacity of this page. pub const Capacity = struct { /// Number of columns and rows we can know about. - cols: usize, - rows: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, /// Number of unique styles that can be used on this page. styles: u16 = 16, @@ -99,8 +111,7 @@ pub const Page = struct { }; /// Initialize a new page, allocating the required backing memory. - /// It is HIGHLY RECOMMENDED you use a page_allocator as the allocator - /// but any allocator is allowed. + /// The size of the initialized page defaults to the full capacity. pub fn init(alloc: Allocator, cap: Capacity) !Page { const l = layout(cap); const backing = try alloc.alignedAlloc(u8, std.mem.page_size, l.total_size); @@ -138,6 +149,7 @@ pub const Page = struct { buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), + .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, }; } @@ -149,7 +161,7 @@ pub const Page = struct { /// Get a single row. y must be valid. pub fn getRow(self: *const Page, y: usize) *Row { - assert(y < self.capacity.rows); + assert(y < self.size.rows); return &self.rows.ptr(self.memory)[y]; } @@ -163,7 +175,7 @@ pub const Page = struct { } const cells = row.cells.ptr(self.memory); - return cells[0..self.capacity.cols]; + return cells[0..self.size.cols]; } /// Get the row and cell for the given X/Y within this page. @@ -171,8 +183,8 @@ pub const Page = struct { row: *Row, cell: *Cell, } { - assert(y < self.capacity.rows); - assert(x < self.capacity.cols); + assert(y < self.size.rows); + assert(x < self.size.cols); const rows = self.rows.ptr(self.memory); const row = &rows[y]; @@ -199,7 +211,7 @@ pub const Page = struct { const rows_start = 0; const rows_end = rows_start + (cap.rows * @sizeOf(Row)); - const cells_count = cap.cols * cap.rows; + const cells_count: usize = @intCast(cap.cols * cap.rows); const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell)); From 5628fa36d87cc5f40bacb41b6d4eb74081a2e60f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 22:00:57 -0800 Subject: [PATCH 035/428] terminal/new: scrollDown --- src/bench/stream.zig | 4 +- src/terminal/new/PageList.zig | 213 ++++++++++++++++++++++++++++++++-- 2 files changed, 204 insertions(+), 13 deletions(-) diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 2deca30874..a0fe6b8618 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -127,8 +127,8 @@ pub fn main() !void { const TerminalStream = terminal.Stream(*NewTerminalHandler); var t = try terminalnew.Terminal.init( alloc, - args.@"terminal-cols", - args.@"terminal-rows", + @intCast(args.@"terminal-cols"), + @intCast(args.@"terminal-rows"), ); var handler: NewTerminalHandler = .{ .t = &t }; var stream: TerminalStream = .{ .handler = &handler }; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 1f00751362..e40b956e91 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -26,6 +26,13 @@ const page_preheat = 4; /// allocate those when necessary. const page_default_styles = 32; +/// Minimum rows we ever initialize a page with. This is wasted memory if +/// too large, but saves us from allocating too many pages when a terminal +/// is small. It also lets us scroll more before we have to allocate more. +/// Tne number 100 is arbitrary. I'm open to changing it if we find a +/// better number. +const page_min_rows: size.CellCountInt = 100; + /// The list of pages in the screen. These are expected to be in order /// where the first page is the topmost page (scrollback) and the last is /// the bottommost page (the current active page). @@ -79,11 +86,12 @@ pub fn init( page.* = .{ .data = try Page.init(alloc, .{ .cols = cols, - .rows = rows, + .rows = @max(rows, page_min_rows), .styles = page_default_styles, }), }; errdefer page.data.deinit(alloc); + page.data.size.rows = rows; var page_list: List = .{}; page_list.prepend(page); @@ -106,6 +114,112 @@ pub fn deinit(self: *PageList) void { self.pool.deinit(); } +/// Scroll the active area down by n lines. If the n lines go beyond the +/// end of the screen, this will add new pages as necessary. This does +/// not move the viewport. +pub fn scrollDown(self: *PageList, n: usize) !void { + // Move our active area down as much as possible towards n. The return + // value is the amount of rows we were short in any existing page, and + // we must expand at least that much. This does not include the size + // of our viewport (rows). + const forward_rem: usize = switch (self.active.forwardOverflow(n)) { + // We have enough rows to move n, so we can just update our active. + // Note we don't yet know if we have enough rows AFTER for the + // active area so we'll have to check that after. + .offset => |v| rem: { + self.active = v; + break :rem 0; + }, + + // We don't have enough rows to even move n. v contains the missing + // amount, so we can allocate pages to fill up the space. + .overflow => |v| rem: { + assert(v.remaining > 0); + self.active = v.end; + break :rem v.remaining; + }, + }; + + // Ensure we have enough rows after the active for the active area. + // Add the forward_rem to add any new pages necessary. + try self.ensureRows(self.active, self.rows + forward_rem); + + // If we needed to move forward more then we have the space now + if (forward_rem > 0) self.active = self.active.forward(forward_rem).?; +} + +/// Ensures that n rows are available AFTER row. If there are not enough +/// rows, this will allocate new pages to fill up the space. This will +/// potentially modify the linked list. +fn ensureRows(self: *PageList, row: RowOffset, n: usize) !void { + var page: *List.Node = row.page; + var n_rem: usize = n; + + // Lower the amount we have left in our page from this offset + n_rem -= page.data.size.rows - row.row_offset; + + // We check if we have capacity to grow in our starting. + if (page.data.size.rows < page.data.capacity.rows) { + // We have extra capacity in this page, so let's grow it + // as much as possible. If we have enough space, use it. + const remaining = page.data.capacity.rows - page.data.size.rows; + if (remaining >= n_rem) { + page.data.size.rows += @intCast(n_rem); + return; + } + + // We don't have enough space for all but we can use some of it. + page.data.size.rows += remaining; + n_rem -= remaining; + + // This panic until we add tests ensure we've never exercised this. + if (true) @panic("TODO: test capacity usage"); + } + + // Its a waste to reach this point if we have enough rows. This assertion + // is here to ensure we never call this in that case, despite the below + // logic being able to handle it. + assert(n_rem > 0); + + // We need to allocate new pages to fill up the remaining space. + while (n_rem > 0) { + const next_page = try self.createPage(); + // we don't errdefer this because we've added it to the linked + // list and its fine to have dangling unused pages. + self.pages.insertAfter(page, next_page); + page = next_page; + + // we expect the pages at this point to be full capacity. we + // shrink them if we have to since they've never been used. + assert(page.data.size.rows == page.data.capacity.rows); + + // If we have enough space, use it. + if (n_rem <= page.data.size.rows) { + page.data.size.rows = @intCast(n_rem); + return; + } + + // Continue + n_rem -= page.data.size.rows; + } +} + +/// Create a new page node. This does not add it to the list. +fn createPage(self: *PageList) !*List.Node { + var page = try self.pool.create(); + errdefer page.data.deinit(self.alloc); + + page.* = .{ + .data = try Page.init(self.alloc, .{ + .cols = self.cols, + .rows = @max(self.rows, page_min_rows), + .styles = page_default_styles, + }), + }; + + return page; +} + /// Get the top-left of the screen for the given tag. pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { // TODO: assert the point is valid @@ -197,23 +311,44 @@ pub const RowOffset = struct { /// /// This will return null if the row index is out of bounds. pub fn forward(self: RowOffset, idx: usize) ?RowOffset { + return switch (self.forwardOverflow(idx)) { + .offset => |v| v, + .overflow => null, + }; + } + + /// Move the offset forward n rows. If the offset goes beyond the + /// end of the screen, return the overflow amount. + fn forwardOverflow(self: RowOffset, n: usize) union(enum) { + offset: RowOffset, + overflow: struct { + end: RowOffset, + remaining: usize, + }, + } { // Index fits within this page - var rows = self.page.data.capacity.rows - self.row_offset; - if (idx < rows) return .{ + var rows = self.page.data.size.rows - (self.row_offset + 1); + if (n <= rows) return .{ .offset = .{ .page = self.page, - .row_offset = idx + self.row_offset, - }; + .row_offset = n + self.row_offset, + } }; // Need to traverse page links to find the page var page: *List.Node = self.page; - var idx_left: usize = idx; - while (idx_left >= rows) { - idx_left -= rows; - page = page.next orelse return null; - rows = page.data.capacity.rows; + var n_left: usize = n; + while (n_left >= rows) { + n_left -= rows; + page = page.next orelse return .{ .overflow = .{ + .end = .{ .page = page, .row_offset = page.data.size.rows - 1 }, + .remaining = n_left, + } }; + rows = page.data.size.rows; } - return .{ .page = page, .row_offset = idx_left }; + return .{ .offset = .{ + .page = page, + .row_offset = n_left, + } }; } }; @@ -231,4 +366,60 @@ test "PageList" { var s = try init(alloc, 80, 24, 1000); defer s.deinit(); + + // Viewport is setup + try testing.expect(s.viewport.page == s.pages.first); + try testing.expect(s.viewport.page.next == null); + try testing.expect(s.viewport.row_offset == 0); + try testing.expect(s.viewport.page.data.size.cols == 80); + try testing.expect(s.viewport.page.data.size.rows == 24); + + // Active area and viewport match + try testing.expectEqual(s.active, s.viewport); +} + +test "scrollDown utilizes capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 1, 1000); + defer s.deinit(); + + // Active is initially at top + try testing.expect(s.active.page == s.pages.first); + try testing.expect(s.active.page.next == null); + try testing.expect(s.active.row_offset == 0); + try testing.expectEqual(@as(size.CellCountInt, 1), s.active.page.data.size.rows); + + try s.scrollDown(1); + + // We should not allocate a new page because we have enough capacity + try testing.expect(s.active.page == s.pages.first); + try testing.expectEqual(@as(size.CellCountInt, 1), s.active.row_offset); + try testing.expect(s.active.page.next == null); + try testing.expectEqual(@as(size.CellCountInt, 2), s.active.page.data.size.rows); +} + +test "scrollDown adds new pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, page_min_rows, 1000); + defer s.deinit(); + + // Active is initially at top + try testing.expect(s.active.page == s.pages.first); + try testing.expect(s.active.page.next == null); + try testing.expect(s.active.row_offset == 0); + + // The initial active is a single page so scrolling down even one + // should force the allocation of an entire new page. + try s.scrollDown(1); + + // We should still be on the first page but offset, and we should + // have a second page created. + try testing.expect(s.active.page == s.pages.first); + try testing.expect(s.active.row_offset == 1); + try testing.expect(s.active.page.next != null); + try testing.expectEqual(@as(size.CellCountInt, 1), s.active.page.next.?.data.size.rows); } From 46b59b4c7d719b91c70bfec0797f207ad7a896ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Feb 2024 22:30:29 -0800 Subject: [PATCH 036/428] terminal/new: scrollactive --- src/bench/stream-new.sh | 30 +++++++++++++++++++++++++++ src/terminal/new/PageList.zig | 32 ++++++++++++++-------------- src/terminal/new/Screen.zig | 18 ++++++++++++++++ src/terminal/new/Terminal.zig | 39 ++++++++++++++++++++++++----------- 4 files changed, 92 insertions(+), 27 deletions(-) create mode 100755 src/bench/stream-new.sh diff --git a/src/bench/stream-new.sh b/src/bench/stream-new.sh new file mode 100755 index 0000000000..69a1c19907 --- /dev/null +++ b/src/bench/stream-new.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# This is a trivial helper script to help run the stream benchmark. +# You probably want to tweak this script depending on what you're +# trying to measure. + +# Options: +# - "ascii", uniform random ASCII bytes +# - "utf8", uniform random unicode characters, encoded as utf8 +# - "rand", pure random data, will contain many invalid code sequences. +DATA="ascii" +SIZE="25000000" + +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +# Generate the benchmark input ahead of time so it's not included in the time. +./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data + +# Uncomment to instead use the contents of `stream.txt` as input. (Ignores SIZE) +# echo $(cat ./stream.txt) > /tmp/ghostty_bench_data + +hyperfine \ + --warmup 10 \ + -n noop \ + "./zig-out/bin/bench-stream --mode=noop = rows) { - n_left -= rows; + var n_left: usize = n - rows; + while (true) { page = page.next orelse return .{ .overflow = .{ .end = .{ .page = page, .row_offset = page.data.size.rows - 1 }, .remaining = n_left, } }; - rows = page.data.size.rows; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .row_offset = n_left - 1, + } }; + n_left -= page.data.size.rows; } - - return .{ .offset = .{ - .page = page, - .row_offset = n_left, - } }; } }; @@ -378,7 +380,7 @@ test "PageList" { try testing.expectEqual(s.active, s.viewport); } -test "scrollDown utilizes capacity" { +test "scrollActive utilizes capacity" { const testing = std.testing; const alloc = testing.allocator; @@ -391,7 +393,7 @@ test "scrollDown utilizes capacity" { try testing.expect(s.active.row_offset == 0); try testing.expectEqual(@as(size.CellCountInt, 1), s.active.page.data.size.rows); - try s.scrollDown(1); + try s.scrollActive(1); // We should not allocate a new page because we have enough capacity try testing.expect(s.active.page == s.pages.first); @@ -400,7 +402,7 @@ test "scrollDown utilizes capacity" { try testing.expectEqual(@as(size.CellCountInt, 2), s.active.page.data.size.rows); } -test "scrollDown adds new pages" { +test "scrollActive adds new pages" { const testing = std.testing; const alloc = testing.allocator; @@ -414,7 +416,7 @@ test "scrollDown adds new pages" { // The initial active is a single page so scrolling down even one // should force the allocation of an entire new page. - try s.scrollDown(1); + try s.scrollActive(1); // We should still be on the first page but offset, and we should // have a second page created. diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 926825e39f..fcf104ba30 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -114,6 +114,24 @@ pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { self.cursor.x = x; } +/// 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); + + // We move the viewport to the active if we're already there to start. + const move_viewport = self.pages.viewport.eql(self.pages.active); + + try self.pages.scrollActive(1); + const page_offset = self.pages.active.forward(self.cursor.y).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + if (move_viewport) self.pages.viewport = self.pages.active; +} + /// 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. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 96b81ae1bf..a7ff10a44d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -173,9 +173,9 @@ pub fn init(alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt) .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, @@ -396,8 +396,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - @panic("TODO: scroll screen"); - //try self.screen.scroll(.{ .screen = 1 }); + try self.screen.cursorDownScroll(); } else { @panic("TODO: scroll up"); //try self.scrollUp(1); @@ -453,6 +452,22 @@ test "Terminal: input with basic wraparound" { } } +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); @@ -466,11 +481,11 @@ test "Terminal: zero-width character at start" { } // https://github.com/mitchellh/ghostty/issues/1400 -// 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'); -// } +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'); +} From f7c597fa9581238ed3f451f3685e4f1f2cf05b6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Feb 2024 09:37:52 -0800 Subject: [PATCH 037/428] terminal/new --- src/bench/stream-new.sh | 7 +++-- src/bench/stream.zig | 16 ++++++++++ src/terminal/new/PageList.zig | 56 +++++++++++++++++++++++++++-------- src/terminal/new/Screen.zig | 31 +++++++++++++++---- src/terminal/new/Terminal.zig | 11 +++---- 5 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/bench/stream-new.sh b/src/bench/stream-new.sh index 69a1c19907..b3d7058a10 100755 --- a/src/bench/stream-new.sh +++ b/src/bench/stream-new.sh @@ -24,7 +24,8 @@ hyperfine \ --warmup 10 \ -n noop \ "./zig-out/bin/bench-stream --mode=noop = 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); // Handle the modes that do not depend on terminal state first. diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 687246db86..a08d204850 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -57,7 +57,7 @@ pages: List, /// - screen: pages.first /// - history: active row minus one /// -viewport: RowOffset, +viewport: Viewport, active: RowOffset, /// The current desired screen dimensions. I say "desired" because individual @@ -66,6 +66,14 @@ active: RowOffset, cols: size.CellCountInt, rows: size.CellCountInt, +/// The viewport location. +pub const Viewport = union(enum) { + /// The viewport is pinned to the active area. By using a specific marker + /// for this instead of tracking the row offset, we eliminate a number of + /// memory writes making scrolling faster. + active, +}; + pub fn init( alloc: Allocator, cols: size.CellCountInt, @@ -96,13 +104,26 @@ pub fn init( var page_list: List = .{}; page_list.prepend(page); + // for (0..1) |_| { + // const p = try pool.create(); + // p.* = .{ + // .data = try Page.init(alloc, .{ + // .cols = cols, + // .rows = @max(rows, page_min_rows), + // .styles = page_default_styles, + // }), + // }; + // p.data.size.rows = 0; + // page_list.append(p); + // } + return .{ .alloc = alloc, .cols = cols, .rows = rows, .pool = pool, .pages = page_list, - .viewport = .{ .page = page }, + .viewport = .{ .active = {} }, .active = .{ .page = page }, }; } @@ -204,6 +225,15 @@ fn ensureRows(self: *PageList, row: RowOffset, n: usize) !void { } } +// TODO: test, refine +pub fn grow(self: *PageList) !*List.Node { + const next_page = try self.createPage(); + // we don't errdefer this because we've added it to the linked + // list and its fine to have dangling unused pages. + self.pages.append(next_page); + return next_page; +} + /// Create a new page node. This does not add it to the list. fn createPage(self: *PageList) !*List.Node { var page = try self.pool.create(); @@ -227,7 +257,9 @@ pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { // This should never return null because we assert the point is valid. return (switch (pt) { .active => |v| self.active.forward(v.y), - .viewport => |v| self.viewport.forward(v.y), + .viewport => |v| switch (self.viewport) { + .active => self.active.forward(v.y), + }, .screen, .history => |v| offset: { const tl: RowOffset = .{ .page = self.pages.first.? }; break :offset tl.forward(v.y); @@ -284,8 +316,10 @@ pub fn rowIterator( fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { return switch (tag) { .active => self.active, - .viewport => self.viewport, .screen, .history => .{ .page = self.pages.first.? }, + .viewport => switch (self.viewport) { + .active => self.active, + }, }; } @@ -370,14 +404,12 @@ test "PageList" { defer s.deinit(); // Viewport is setup - try testing.expect(s.viewport.page == s.pages.first); - try testing.expect(s.viewport.page.next == null); - try testing.expect(s.viewport.row_offset == 0); - try testing.expect(s.viewport.page.data.size.cols == 80); - try testing.expect(s.viewport.page.data.size.rows == 24); - - // Active area and viewport match - try testing.expectEqual(s.active, s.viewport); + try testing.expect(s.viewport == .active); + try testing.expect(s.active.page == s.pages.first); + try testing.expect(s.active.page.next == null); + try testing.expect(s.active.row_offset == 0); + try testing.expect(s.active.page.data.size.cols == 80); + try testing.expect(s.active.page.data.size.rows == 24); } test "scrollActive utilizes capacity" { diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index fcf104ba30..5082b41f26 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -119,17 +119,38 @@ pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { pub fn cursorDownScroll(self: *Screen) !void { assert(self.cursor.y == self.pages.rows - 1); - // We move the viewport to the active if we're already there to start. - const move_viewport = self.pages.viewport.eql(self.pages.active); + // If we have cap space in our current cursor page then we can take + // a fast path: update the size, recalculate the row/cell cursor pointers. + const cursor_page = self.cursor.page_offset.page; + if (cursor_page.data.capacity.rows > cursor_page.data.size.rows) { + cursor_page.data.size.rows += 1; + + const page_offset = self.cursor.page_offset.forward(1).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + return; + } + + // No space, we need to allocate a new page and move the cursor to it. - try self.pages.scrollActive(1); - const page_offset = self.pages.active.forward(self.cursor.y).?; + const new_page = try self.pages.grow(); + const page_offset: PageList.RowOffset = .{ + .page = new_page, + .row_offset = 0, + }; const page_rac = page_offset.rowAndCell(self.cursor.x); self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - if (move_viewport) self.pages.viewport = self.pages.active; + // try self.pages.scrollActive(1); + // const page_offset = self.pages.active.forward(self.cursor.y).?; + // const page_rac = page_offset.rowAndCell(self.cursor.x); + // self.cursor.page_offset = page_offset; + // self.cursor.page_row = page_rac.row; + // self.cursor.page_cell = page_rac.cell; } /// Dump the screen to a string. The writer given should be buffered; diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index a7ff10a44d..6f5af80f99 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -461,11 +461,12 @@ test "Terminal: input that forces scroll" { 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); - } + // TODO once viewport is moved + // { + // 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" { From 424f1211047cb9f430922f47520c46eaf9b80867 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Feb 2024 10:27:18 -0800 Subject: [PATCH 038/428] terminal/new: pages must use mmap directly --- src/terminal/new/PageList.zig | 8 +++---- src/terminal/new/page.zig | 41 +++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index a08d204850..f2c6d3f7da 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -92,7 +92,7 @@ pub fn init( // no errdefer because the pool deinit will clean up the page page.* = .{ - .data = try Page.init(alloc, .{ + .data = try Page.init(.{ .cols = cols, .rows = @max(rows, page_min_rows), .styles = page_default_styles, @@ -131,7 +131,7 @@ pub fn init( pub fn deinit(self: *PageList) void { // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. - while (self.pages.popFirst()) |node| node.data.deinit(self.alloc); + while (self.pages.popFirst()) |node| node.data.deinit(); self.pool.deinit(); } @@ -237,10 +237,10 @@ pub fn grow(self: *PageList) !*List.Node { /// Create a new page node. This does not add it to the list. fn createPage(self: *PageList) !*List.Node { var page = try self.pool.create(); - errdefer page.data.deinit(self.alloc); + errdefer page.data.deinit(); page.* = .{ - .data = try Page.init(self.alloc, .{ + .data = try Page.init(.{ .cols = self.cols, .rows = @max(self.rows, page_min_rows), .styles = page_default_styles, diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 7b42d72401..af3d4d6dc5 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -112,11 +112,27 @@ pub const Page = struct { /// Initialize a new page, allocating the required backing memory. /// The size of the initialized page defaults to the full capacity. - pub fn init(alloc: Allocator, cap: Capacity) !Page { + /// + /// The backing memory is always allocated using mmap directly. + /// You cannot use custom allocators with this structure because + /// it is critical to performance that we use mmap. + pub fn init(cap: Capacity) !Page { const l = layout(cap); - const backing = try alloc.alignedAlloc(u8, std.mem.page_size, l.total_size); - errdefer alloc.free(backing); - @memset(backing, 0); + + // We use mmap directly to avoid Zig allocator overhead + // (small but meaningful for this path) and because a private + // anonymous mmap is guaranteed on Linux and macOS to be zeroed, + // which is a critical property for us. + assert(l.total_size % std.mem.page_size == 0); + const backing = try std.os.mmap( + null, + l.total_size, + std.os.PROT.READ | std.os.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer std.os.munmap(backing); const buf = OffsetBuf.init(backing); const rows = buf.member(Row, l.rows_start); @@ -154,8 +170,8 @@ pub const Page = struct { }; } - pub fn deinit(self: *Page, alloc: Allocator) void { - alloc.free(self.memory); + pub fn deinit(self: *Page) void { + std.os.munmap(self.memory); self.* = undefined; } @@ -228,7 +244,7 @@ pub const Page = struct { const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; - const total_size = grapheme_map_end; + const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); return .{ .total_size = total_size, @@ -317,25 +333,22 @@ pub const Cell = packed struct(u64) { // } test "Page init" { - const testing = std.testing; - const alloc = testing.allocator; - var page = try Page.init(alloc, .{ + var page = try Page.init(.{ .cols = 120, .rows = 80, .styles = 32, }); - defer page.deinit(alloc); + defer page.deinit(); } test "Page read and write cells" { const testing = std.testing; - const alloc = testing.allocator; - var page = try Page.init(alloc, .{ + var page = try Page.init(.{ .cols = 10, .rows = 10, .styles = 8, }); - defer page.deinit(alloc); + defer page.deinit(); for (0..page.capacity.rows) |y| { const rac = page.getRowAndCell(1, y); From 7ad94caaebf50bc54f2904a2b0a2d975c9947f64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Feb 2024 19:51:38 -0800 Subject: [PATCH 039/428] bench/page-init --- src/bench/page-init.sh | 14 +++++++++ src/bench/page-init.zig | 60 +++++++++++++++++++++++++++++++++++++++ src/build_config.zig | 1 + src/main.zig | 1 + src/terminal/main.zig | 5 ++-- src/terminal/new/main.zig | 2 ++ src/terminal/new/page.zig | 17 +++++++++-- 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100755 src/bench/page-init.sh create mode 100644 src/bench/page-init.zig diff --git a/src/bench/page-init.sh b/src/bench/page-init.sh new file mode 100755 index 0000000000..f54df627bd --- /dev/null +++ b/src/bench/page-init.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# +# This is a trivial helper script to help run the page init benchmark. +# You probably want to tweak this script depending on what you're +# trying to measure. + +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +hyperfine \ + --warmup 10 \ + -n alloc \ + "./zig-out/bin/bench-page-init --mode=alloc${ARGS} try benchAlloc(args.count), + } +} + +noinline fn benchAlloc(count: usize) !void { + for (0..count) |_| { + _ = try terminal.new.Page.init(terminal.new.Page.std_capacity); + } +} diff --git a/src/build_config.zig b/src/build_config.zig index 803e3ee4d2..742a2b692b 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -143,4 +143,5 @@ pub const ExeEntrypoint = enum { bench_stream, bench_codepoint_width, bench_grapheme_break, + bench_page_init, }; diff --git a/src/main.zig b/src/main.zig index 8cad7ec9fe..3a5357471b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,4 +10,5 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_stream => @import("bench/stream.zig"), .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), + .bench_page_init => @import("bench/page-init.zig"), }; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 45da8564e1..ab5bad4d6c 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -48,8 +48,9 @@ pub usingnamespace if (builtin.target.isWasm()) struct { pub usingnamespace @import("wasm.zig"); } else struct {}; +/// The new stuff. TODO: remove this before merge. +pub const new = @import("new/main.zig"); + test { @import("std").testing.refAllDecls(@This()); - - _ = @import("new/main.zig"); } diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index 93ea0e04e6..caf7c1920f 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -1,6 +1,8 @@ const builtin = @import("builtin"); +const page = @import("page.zig"); pub const Terminal = @import("Terminal.zig"); +pub const Page = page.Page; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index af3d4d6dc5..ce21e6b793 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -91,6 +91,16 @@ pub const Page = struct { const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + /// The standard capacity for a page that doesn't have special + /// requirements. This is enough to support a very large number of cells. + /// The standard capacity is chosen as the fast-path for allocation. + pub const std_capacity: Capacity = .{ + .cols = 250, + .rows = 250, + .styles = 128, + .grapheme_bytes = 1024, + }; + /// The size of this page. pub const Size = struct { cols: size.CellCountInt, @@ -319,9 +329,10 @@ pub const Cell = packed struct(u64) { // const total_size = alignForward( // usize, // Page.layout(.{ -// .cols = 333, -// .rows = 81, -// .styles = 32, +// .cols = 250, +// .rows = 250, +// .styles = 128, +// .grapheme_bytes = 1024, // }).total_size, // std.mem.page_size, // ); From 396cf5eb7a5f918785e0937b1ac28fa030e623f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Feb 2024 21:23:05 -0800 Subject: [PATCH 040/428] bench/page-init: page count --- src/bench/page-init.zig | 2 +- src/terminal/new/main.zig | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bench/page-init.zig b/src/bench/page-init.zig index 1540c59e4c..d00ab833d7 100644 --- a/src/bench/page-init.zig +++ b/src/bench/page-init.zig @@ -14,7 +14,7 @@ const Args = struct { mode: Mode = .alloc, /// The number of pages to create sequentially. - count: usize = 20_000, + count: usize = 208_235, /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index caf7c1920f..b4c3ee8a94 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const page = @import("page.zig"); +pub const PageList = @import("PageList.zig"); pub const Terminal = @import("Terminal.zig"); pub const Page = page.Page; From f929c86d18a668d9436394cfedc5c5d92e9fffa5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Feb 2024 21:52:20 -0800 Subject: [PATCH 041/428] terminal/new: fix allocation --- src/terminal/new/PageList.zig | 11 +++++------ src/terminal/new/PagePool.zig | 8 ++++++++ src/terminal/new/Screen.zig | 2 ++ src/terminal/new/main.zig | 2 +- src/terminal/new/page.zig | 4 ++-- 5 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 src/terminal/new/PagePool.zig diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index f2c6d3f7da..313847d96e 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -210,22 +210,20 @@ fn ensureRows(self: *PageList, row: RowOffset, n: usize) !void { self.pages.insertAfter(page, next_page); page = next_page; - // we expect the pages at this point to be full capacity. we - // shrink them if we have to since they've never been used. - assert(page.data.size.rows == page.data.capacity.rows); - // If we have enough space, use it. - if (n_rem <= page.data.size.rows) { + if (n_rem <= page.data.capacity.rows) { page.data.size.rows = @intCast(n_rem); return; } + // created pages are always empty so fill it with blanks + page.data.size.rows = page.data.capacity.rows; + // Continue n_rem -= page.data.size.rows; } } -// TODO: test, refine pub fn grow(self: *PageList) !*List.Node { const next_page = try self.createPage(); // we don't errdefer this because we've added it to the linked @@ -246,6 +244,7 @@ fn createPage(self: *PageList) !*List.Node { .styles = page_default_styles, }), }; + page.data.size.rows = 0; return page; } diff --git a/src/terminal/new/PagePool.zig b/src/terminal/new/PagePool.zig new file mode 100644 index 0000000000..52790a7d02 --- /dev/null +++ b/src/terminal/new/PagePool.zig @@ -0,0 +1,8 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +test { + const testing = std.testing; + try testing.expect(false); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 5082b41f26..4d899a1fdf 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -136,6 +136,8 @@ pub fn cursorDownScroll(self: *Screen) !void { // No space, we need to allocate a new page and move the cursor to it. const new_page = try self.pages.grow(); + assert(new_page.data.size.rows == 0); + new_page.data.size.rows = 1; const page_offset: PageList.RowOffset = .{ .page = new_page, .row_offset = 0, diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index b4c3ee8a94..40b88e0131 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -2,6 +2,7 @@ const builtin = @import("builtin"); const page = @import("page.zig"); pub const PageList = @import("PageList.zig"); +pub const PagePool = @import("PagePool.zig"); pub const Terminal = @import("Terminal.zig"); pub const Page = page.Page; @@ -12,7 +13,6 @@ test { _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); _ = @import("page.zig"); - _ = @import("PageList.zig"); _ = @import("Screen.zig"); _ = @import("point.zig"); _ = @import("size.zig"); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index ce21e6b793..99f6d1b5fa 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -95,8 +95,8 @@ pub const Page = struct { /// requirements. This is enough to support a very large number of cells. /// The standard capacity is chosen as the fast-path for allocation. pub const std_capacity: Capacity = .{ - .cols = 250, - .rows = 250, + .cols = 120, + .rows = 520, .styles = 128, .grapheme_bytes = 1024, }; From f2d4b64032d5908a63108f6f91c1c6f27ac711d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 08:05:37 -0800 Subject: [PATCH 042/428] terminal/new: using arena + pool is faster for page init --- src/terminal/new/PageList.zig | 27 ++++++++++++++++----------- src/terminal/new/PagePool.zig | 8 -------- src/terminal/new/main.zig | 1 - src/terminal/new/page.zig | 30 ++++++++++++++++++++++++------ 4 files changed, 40 insertions(+), 26 deletions(-) delete mode 100644 src/terminal/new/PagePool.zig diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 313847d96e..1bafdc1470 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -9,6 +9,7 @@ const assert = std.debug.assert; const point = @import("point.zig"); const pagepkg = @import("page.zig"); const size = @import("size.zig"); +const OffsetBuf = size.OffsetBuf; const Page = pagepkg.Page; /// The number of PageList.Nodes we preheat the pool with. A node is @@ -41,12 +42,17 @@ const List = std.DoublyLinkedList(Page); /// The memory pool we get page nodes from. const Pool = std.heap.MemoryPool(List.Node); +const std_layout = Page.layout(Page.std_capacity); +const PagePool = std.heap.MemoryPoolAligned([std_layout.total_size]u8, std.mem.page_size); + /// The allocator to use for pages. alloc: Allocator, /// The memory pool we get page nodes for the linked list from. pool: Pool, +page_pool: PagePool, + /// The list of pages in the screen. pages: List, @@ -88,15 +94,15 @@ pub fn init( var pool = try Pool.initPreheated(alloc, page_preheat); errdefer pool.deinit(); + var page_pool = try PagePool.initPreheated(std.heap.page_allocator, page_preheat); + errdefer page_pool.deinit(); + var page = try pool.create(); // no errdefer because the pool deinit will clean up the page + const page_buf = OffsetBuf.init(try page_pool.create()); page.* = .{ - .data = try Page.init(.{ - .cols = cols, - .rows = @max(rows, page_min_rows), - .styles = page_default_styles, - }), + .data = Page.initBuf(page_buf, std_layout), }; errdefer page.data.deinit(alloc); page.data.size.rows = rows; @@ -122,6 +128,7 @@ pub fn init( .cols = cols, .rows = rows, .pool = pool, + .page_pool = page_pool, .pages = page_list, .viewport = .{ .active = {} }, .active = .{ .page = page }, @@ -131,7 +138,7 @@ pub fn init( pub fn deinit(self: *PageList) void { // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. - while (self.pages.popFirst()) |node| node.data.deinit(); + self.page_pool.deinit(); self.pool.deinit(); } @@ -237,12 +244,10 @@ fn createPage(self: *PageList) !*List.Node { var page = try self.pool.create(); errdefer page.data.deinit(); + const page_buf = OffsetBuf.init(try self.page_pool.create()); + page.* = .{ - .data = try Page.init(.{ - .cols = self.cols, - .rows = @max(self.rows, page_min_rows), - .styles = page_default_styles, - }), + .data = Page.initBuf(page_buf, std_layout), }; page.data.size.rows = 0; diff --git a/src/terminal/new/PagePool.zig b/src/terminal/new/PagePool.zig deleted file mode 100644 index 52790a7d02..0000000000 --- a/src/terminal/new/PagePool.zig +++ /dev/null @@ -1,8 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -test { - const testing = std.testing; - try testing.expect(false); -} diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index 40b88e0131..cac9fa6abc 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -2,7 +2,6 @@ const builtin = @import("builtin"); const page = @import("page.zig"); pub const PageList = @import("PageList.zig"); -pub const PagePool = @import("PagePool.zig"); pub const Terminal = @import("Terminal.zig"); pub const Page = page.Page; diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 99f6d1b5fa..848cf5cedd 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const testing = std.testing; const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const style = @import("style.zig"); @@ -95,8 +96,8 @@ pub const Page = struct { /// requirements. This is enough to support a very large number of cells. /// The standard capacity is chosen as the fast-path for allocation. pub const std_capacity: Capacity = .{ - .cols = 120, - .rows = 520, + .cols = 250, + .rows = 250, .styles = 128, .grapheme_bytes = 1024, }; @@ -145,6 +146,13 @@ pub const Page = struct { errdefer std.os.munmap(backing); const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + } + + /// Initialize a new page using the given backing memory. + /// It is up to the caller to not call deinit on these pages. + pub fn initBuf(buf: OffsetBuf, l: Layout) Page { + const cap = l.capacity; const rows = buf.member(Row, l.rows_start); const cells = buf.member(Cell, l.cells_start); @@ -160,7 +168,7 @@ pub const Page = struct { } return .{ - .memory = backing, + .memory = @alignCast(buf.start()[0..l.total_size]), .rows = rows, .cells = cells, .styles = style.Set.init( @@ -219,7 +227,7 @@ pub const Page = struct { return .{ .row = row, .cell = cell }; } - const Layout = struct { + pub const Layout = struct { total_size: usize, rows_start: usize, cells_start: usize, @@ -229,11 +237,12 @@ pub const Page = struct { grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, grapheme_map_layout: GraphemeMap.Layout, + capacity: Capacity, }; /// The memory layout for a page given a desired minimum cols /// and rows size. - fn layout(cap: Capacity) Layout { + pub fn layout(cap: Capacity) Layout { const rows_start = 0; const rows_end = rows_start + (cap.rows * @sizeOf(Row)); @@ -266,6 +275,7 @@ pub const Page = struct { .grapheme_alloc_layout = grapheme_alloc_layout, .grapheme_map_start = grapheme_map_start, .grapheme_map_layout = grapheme_map_layout, + .capacity = cap, }; } }; @@ -343,6 +353,15 @@ pub const Cell = packed struct(u64) { // }); // } +test "Page std size" { + // We want to ensure that the standard capacity is what we + // expect it to be. Changing this is fine but should be done with care + // so we fail a test if it changes. + const total_size = Page.layout(Page.std_capacity).total_size; + try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB + //const pages = total_size / std.mem.page_size; +} + test "Page init" { var page = try Page.init(.{ .cols = 120, @@ -353,7 +372,6 @@ test "Page init" { } test "Page read and write cells" { - const testing = std.testing; var page = try Page.init(.{ .cols = 10, .rows = 10, From 1070f045ff4e6ca75ec33029ebd324f95e001d8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 09:16:55 -0800 Subject: [PATCH 043/428] terminal/new: page capacity can be adjusted while retaining byte size --- src/terminal/new/PageList.zig | 2 +- src/terminal/new/page.zig | 165 +++++++++++++++++++++++++--------- 2 files changed, 123 insertions(+), 44 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 1bafdc1470..f3cdd95c39 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -42,7 +42,7 @@ const List = std.DoublyLinkedList(Page); /// The memory pool we get page nodes from. const Pool = std.heap.MemoryPool(List.Node); -const std_layout = Page.layout(Page.std_capacity); +const std_layout = Page.layout(pagepkg.std_capacity); const PagePool = std.heap.MemoryPoolAligned([std_layout.total_size]u8, std.mem.page_size); /// The allocator to use for pages. diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 848cf5cedd..3971e3d223 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -14,6 +14,18 @@ const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const alignForward = std.mem.alignForward; +/// The allocator to use for multi-codepoint grapheme data. We use +/// a chunk size of 4 codepoints. It'd be best to set this empirically +/// but it is currently set based on vibes. My thinking around 4 codepoints +/// is that most skin-tone emoji are <= 4 codepoints, letter combiners +/// are usually <= 4 codepoints, and 4 codepoints is a nice power of two +/// for alignment. +const grapheme_chunk = 4 * @sizeOf(u21); +const GraphemeAlloc = BitmapAllocator(grapheme_chunk); +const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; +const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; +const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -80,47 +92,6 @@ pub const Page = struct { /// memory and is fixed at page creation time. capacity: Capacity, - /// The allocator to use for multi-codepoint grapheme data. We use - /// a chunk size of 4 codepoints. It'd be best to set this empirically - /// but it is currently set based on vibes. My thinking around 4 codepoints - /// is that most skin-tone emoji are <= 4 codepoints, letter combiners - /// are usually <= 4 codepoints, and 4 codepoints is a nice power of two - /// for alignment. - const grapheme_chunk = 4 * @sizeOf(u21); - const GraphemeAlloc = BitmapAllocator(grapheme_chunk); - const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; - const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; - const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); - - /// The standard capacity for a page that doesn't have special - /// requirements. This is enough to support a very large number of cells. - /// The standard capacity is chosen as the fast-path for allocation. - pub const std_capacity: Capacity = .{ - .cols = 250, - .rows = 250, - .styles = 128, - .grapheme_bytes = 1024, - }; - - /// The size of this page. - pub const Size = struct { - cols: size.CellCountInt, - rows: size.CellCountInt, - }; - - /// Capacity of this page. - pub const Capacity = struct { - /// Number of columns and rows we can know about. - cols: size.CellCountInt, - rows: size.CellCountInt, - - /// Number of unique styles that can be used on this page. - styles: u16 = 16, - - /// Number of bytes to allocate for grapheme data. - grapheme_bytes: usize = grapheme_bytes_default, - }; - /// Initialize a new page, allocating the required backing memory. /// The size of the initialized page defaults to the full capacity. /// @@ -188,6 +159,9 @@ pub const Page = struct { }; } + /// Deinitialize the page, freeing any backing memory. Do NOT call + /// this if you allocated the backing memory yourself (i.e. you used + /// initBuf). pub fn deinit(self: *Page) void { std.os.munmap(self.memory); self.* = undefined; @@ -230,7 +204,9 @@ pub const Page = struct { pub const Layout = struct { total_size: usize, rows_start: usize, + rows_size: usize, cells_start: usize, + cells_size: usize, styles_start: usize, styles_layout: style.Set.Layout, grapheme_alloc_start: usize, @@ -243,8 +219,9 @@ pub const Page = struct { /// The memory layout for a page given a desired minimum cols /// and rows size. pub fn layout(cap: Capacity) Layout { + const rows_count: usize = @intCast(cap.rows); const rows_start = 0; - const rows_end = rows_start + (cap.rows * @sizeOf(Row)); + const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); const cells_count: usize = @intCast(cap.cols * cap.rows); const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); @@ -268,7 +245,9 @@ pub const Page = struct { return .{ .total_size = total_size, .rows_start = rows_start, + .rows_size = rows_end - rows_start, .cells_start = cells_start, + .cells_size = cells_end - cells_start, .styles_start = styles_start, .styles_layout = styles_layout, .grapheme_alloc_start = grapheme_alloc_start, @@ -280,6 +259,74 @@ pub const Page = struct { } }; +/// The standard capacity for a page that doesn't have special +/// requirements. This is enough to support a very large number of cells. +/// The standard capacity is chosen as the fast-path for allocation. +pub const std_capacity: Capacity = .{ + .cols = 250, + .rows = 250, + .styles = 128, + .grapheme_bytes = 1024, +}; + +/// The size of this page. +pub const Size = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, +}; + +/// Capacity of this page. +pub const Capacity = struct { + /// Number of columns and rows we can know about. + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Number of unique styles that can be used on this page. + styles: u16 = 16, + + /// Number of bytes to allocate for grapheme data. + grapheme_bytes: usize = grapheme_bytes_default, + + pub const Adjustment = struct { + cols: ?size.CellCountInt = null, + }; + + /// Adjust the capacity parameters while retaining the same total size. + /// Adjustments always happen by limiting the rows in the page. Everything + /// else can grow. If it is impossible to achieve the desired adjustment, + /// OutOfMemory is returned. + pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity { + var adjusted = self; + if (req.cols) |cols| { + // The calculations below only work if cells/rows match size. + assert(@sizeOf(Cell) == @sizeOf(Row)); + + // total_size = (Nrows * sizeOf(Row)) + (Nrows * Ncells * sizeOf(Cell)) + // with some algebra: + // Nrows = total_size / (sizeOf(Row) + (Ncells * sizeOf(Cell))) + const layout = Page.layout(self); + const total_size = layout.rows_size + layout.cells_size; + const denom = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); + const new_rows = @divFloor(total_size, denom); + + // If our rows go to zero then we can't fit any row metadata + // for the desired number of columns. + if (new_rows == 0) return error.OutOfMemory; + + adjusted.cols = cols; + adjusted.rows = @intCast(new_rows); + } + + if (comptime std.debug.runtime_safety) { + const old_size = Page.layout(self).total_size; + const new_size = Page.layout(adjusted).total_size; + assert(new_size == old_size); + } + + return adjusted; + } +}; + pub const Row = packed struct(u64) { _padding: u29 = 0, @@ -357,11 +404,43 @@ test "Page std size" { // We want to ensure that the standard capacity is what we // expect it to be. Changing this is fine but should be done with care // so we fail a test if it changes. - const total_size = Page.layout(Page.std_capacity).total_size; + const total_size = Page.layout(std_capacity).total_size; try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB //const pages = total_size / std.mem.page_size; } +test "Page capacity adjust cols down" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols / 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); +} + +test "Page capacity adjust cols down to 1" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = 1 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); +} + +test "Page capacity adjust cols up" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols * 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); +} + +test "Page capacity adjust cols too high" { + const original = std_capacity; + try testing.expectError( + error.OutOfMemory, + original.adjust(.{ .cols = std.math.maxInt(size.CellCountInt) }), + ); +} + test "Page init" { var page = try Page.init(.{ .cols = 120, From 1088176f94b3a6c67d1d96abe7dabe28bc37d9a9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 12:46:01 -0800 Subject: [PATCH 044/428] terminal/new: create in proper sizes --- src/terminal/new/PageList.zig | 157 ++++------------------------------ 1 file changed, 16 insertions(+), 141 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index f3cdd95c39..e605f26e7a 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -42,7 +42,8 @@ const List = std.DoublyLinkedList(Page); /// The memory pool we get page nodes from. const Pool = std.heap.MemoryPool(List.Node); -const std_layout = Page.layout(pagepkg.std_capacity); +const std_capacity = pagepkg.std_capacity; +const std_layout = Page.layout(std_capacity); const PagePool = std.heap.MemoryPoolAligned([std_layout.total_size]u8, std.mem.page_size); /// The allocator to use for pages. @@ -99,10 +100,14 @@ pub fn init( var page = try pool.create(); // no errdefer because the pool deinit will clean up the page - const page_buf = OffsetBuf.init(try page_pool.create()); + const page_buf = try page_pool.create(); + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); page.* = .{ - .data = Page.initBuf(page_buf, std_layout), + .data = Page.initBuf( + OffsetBuf.init(page_buf), + Page.layout(try std_capacity.adjust(.{ .cols = cols })), + ), }; errdefer page.data.deinit(alloc); page.data.size.rows = rows; @@ -142,95 +147,6 @@ pub fn deinit(self: *PageList) void { self.pool.deinit(); } -/// Scroll the active area down by n lines. If the n lines go beyond the -/// end of the screen, this will add new pages as necessary. This does -/// not move the viewport. -pub fn scrollActive(self: *PageList, n: usize) !void { - // Move our active area down as much as possible towards n. The return - // value is the amount of rows we were short in any existing page, and - // we must expand at least that much. This does not include the size - // of our viewport (rows). - const forward_rem: usize = switch (self.active.forwardOverflow(n)) { - // We have enough rows to move n, so we can just update our active. - // Note we don't yet know if we have enough rows AFTER for the - // active area so we'll have to check that after. - .offset => |v| rem: { - self.active = v; - break :rem 0; - }, - - // We don't have enough rows to even move n. v contains the missing - // amount, so we can allocate pages to fill up the space. - .overflow => |v| rem: { - assert(v.remaining > 0); - self.active = v.end; - break :rem v.remaining; - }, - }; - - // Ensure we have enough rows after the active for the active area. - // Add the forward_rem to add any new pages necessary. - try self.ensureRows(self.active, self.rows + forward_rem); - - // If we needed to move forward more then we have the space now - if (forward_rem > 0) self.active = self.active.forward(forward_rem).?; -} - -/// Ensures that n rows are available AFTER row. If there are not enough -/// rows, this will allocate new pages to fill up the space. This will -/// potentially modify the linked list. -fn ensureRows(self: *PageList, row: RowOffset, n: usize) !void { - var page: *List.Node = row.page; - var n_rem: usize = n; - - // Lower the amount we have left in our page from this offset - n_rem -= page.data.size.rows - row.row_offset; - - // We check if we have capacity to grow in our starting. - if (page.data.size.rows < page.data.capacity.rows) { - // We have extra capacity in this page, so let's grow it - // as much as possible. If we have enough space, use it. - const remaining = page.data.capacity.rows - page.data.size.rows; - if (remaining >= n_rem) { - page.data.size.rows += @intCast(n_rem); - return; - } - - // We don't have enough space for all but we can use some of it. - page.data.size.rows += remaining; - n_rem -= remaining; - - // This panic until we add tests ensure we've never exercised this. - if (true) @panic("TODO: test capacity usage"); - } - - // Its a waste to reach this point if we have enough rows. This assertion - // is here to ensure we never call this in that case, despite the below - // logic being able to handle it. - assert(n_rem > 0); - - // We need to allocate new pages to fill up the remaining space. - while (n_rem > 0) { - const next_page = try self.createPage(); - // we don't errdefer this because we've added it to the linked - // list and its fine to have dangling unused pages. - self.pages.insertAfter(page, next_page); - page = next_page; - - // If we have enough space, use it. - if (n_rem <= page.data.capacity.rows) { - page.data.size.rows = @intCast(n_rem); - return; - } - - // created pages are always empty so fill it with blanks - page.data.size.rows = page.data.capacity.rows; - - // Continue - n_rem -= page.data.size.rows; - } -} - pub fn grow(self: *PageList) !*List.Node { const next_page = try self.createPage(); // we don't errdefer this because we've added it to the linked @@ -242,12 +158,17 @@ pub fn grow(self: *PageList) !*List.Node { /// Create a new page node. This does not add it to the list. fn createPage(self: *PageList) !*List.Node { var page = try self.pool.create(); - errdefer page.data.deinit(); + errdefer self.pool.destroy(page); - const page_buf = OffsetBuf.init(try self.page_pool.create()); + const page_buf = try self.page_pool.create(); + errdefer self.page_pool.destroy(page_buf); + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); page.* = .{ - .data = Page.initBuf(page_buf, std_layout), + .data = Page.initBuf( + OffsetBuf.init(page_buf), + Page.layout(try std_capacity.adjust(.{ .cols = self.cols })), + ), }; page.data.size.rows = 0; @@ -415,49 +336,3 @@ test "PageList" { try testing.expect(s.active.page.data.size.cols == 80); try testing.expect(s.active.page.data.size.rows == 24); } - -test "scrollActive utilizes capacity" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 80, 1, 1000); - defer s.deinit(); - - // Active is initially at top - try testing.expect(s.active.page == s.pages.first); - try testing.expect(s.active.page.next == null); - try testing.expect(s.active.row_offset == 0); - try testing.expectEqual(@as(size.CellCountInt, 1), s.active.page.data.size.rows); - - try s.scrollActive(1); - - // We should not allocate a new page because we have enough capacity - try testing.expect(s.active.page == s.pages.first); - try testing.expectEqual(@as(size.CellCountInt, 1), s.active.row_offset); - try testing.expect(s.active.page.next == null); - try testing.expectEqual(@as(size.CellCountInt, 2), s.active.page.data.size.rows); -} - -test "scrollActive adds new pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 80, page_min_rows, 1000); - defer s.deinit(); - - // Active is initially at top - try testing.expect(s.active.page == s.pages.first); - try testing.expect(s.active.page.next == null); - try testing.expect(s.active.row_offset == 0); - - // The initial active is a single page so scrolling down even one - // should force the allocation of an entire new page. - try s.scrollActive(1); - - // We should still be on the first page but offset, and we should - // have a second page created. - try testing.expect(s.active.page == s.pages.first); - try testing.expect(s.active.row_offset == 1); - try testing.expect(s.active.page.next != null); - try testing.expectEqual(@as(size.CellCountInt, 1), s.active.page.next.?.data.size.rows); -} From de3d1e4df792dbef6be934a281efe560d07abea8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 12:58:48 -0800 Subject: [PATCH 045/428] terminal/new: clean up --- src/bench/page-init.sh | 4 ++- src/bench/page-init.zig | 22 +++++++++++++++-- src/terminal/new/PageList.zig | 46 ++++++++++++----------------------- src/terminal/new/Screen.zig | 2 +- src/terminal/new/main.zig | 2 +- 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/bench/page-init.sh b/src/bench/page-init.sh index f54df627bd..54712250bd 100755 --- a/src/bench/page-init.sh +++ b/src/bench/page-init.sh @@ -10,5 +10,7 @@ hyperfine \ --warmup 10 \ -n alloc \ - "./zig-out/bin/bench-page-init --mode=alloc${ARGS} try benchAlloc(args.count), + .pool => try benchPool(alloc, args.count), } } noinline fn benchAlloc(count: usize) !void { for (0..count) |_| { - _ = try terminal.new.Page.init(terminal.new.Page.std_capacity); + _ = try terminal.new.Page.init(terminal.new.page.std_capacity); + } +} + +noinline fn benchPool(alloc: Allocator, count: usize) !void { + var list = try terminal.new.PageList.init( + alloc, + terminal.new.page.std_capacity.cols, + terminal.new.page.std_capacity.rows, + 0, + ); + defer list.deinit(); + + for (0..count) |_| { + _ = try list.grow(); } } diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index e605f26e7a..2f3d407dc9 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -20,20 +20,6 @@ const Page = pagepkg.Page; /// window to scroll into quickly. const page_preheat = 4; -/// The default number of unique styles per page we expect. It is currently -/// "32" because anecdotally amongst a handful of beta testers, no one -/// under normal terminal use ever used more than 32 unique styles in a -/// single page. We found outliers but it was rare enough that we could -/// allocate those when necessary. -const page_default_styles = 32; - -/// Minimum rows we ever initialize a page with. This is wasted memory if -/// too large, but saves us from allocating too many pages when a terminal -/// is small. It also lets us scroll more before we have to allocate more. -/// Tne number 100 is arbitrary. I'm open to changing it if we find a -/// better number. -const page_min_rows: size.CellCountInt = 100; - /// The list of pages in the screen. These are expected to be in order /// where the first page is the topmost page (scrollback) and the last is /// the bottommost page (the current active page). @@ -43,8 +29,14 @@ const List = std.DoublyLinkedList(Page); const Pool = std.heap.MemoryPool(List.Node); const std_capacity = pagepkg.std_capacity; -const std_layout = Page.layout(std_capacity); -const PagePool = std.heap.MemoryPoolAligned([std_layout.total_size]u8, std.mem.page_size); + +/// The memory pool we use for page memory buffers. We use a separate pool +/// so we can allocate these with a page allocator. We have to use a page +/// allocator because we need memory that is zero-initialized and page-aligned. +const PagePool = std.heap.MemoryPoolAligned( + [Page.layout(std_capacity).total_size]u8, + std.mem.page_size, +); /// The allocator to use for pages. alloc: Allocator, @@ -81,6 +73,9 @@ pub const Viewport = union(enum) { active, }; +/// Initialize the page. The top of the first page in the list is always the +/// top of the active area of the screen (important knowledge for quickly +/// setting up cursors in Screen). pub fn init( alloc: Allocator, cols: size.CellCountInt, @@ -99,35 +94,24 @@ pub fn init( errdefer page_pool.deinit(); var page = try pool.create(); - // no errdefer because the pool deinit will clean up the page const page_buf = try page_pool.create(); if (comptime std.debug.runtime_safety) @memset(page_buf, 0); + // no errdefer because the pool deinit will clean these up + // Initialize the first set of pages to contain our viewport so that + // the top of the first page is always the active area. page.* = .{ .data = Page.initBuf( OffsetBuf.init(page_buf), Page.layout(try std_capacity.adjust(.{ .cols = cols })), ), }; - errdefer page.data.deinit(alloc); + assert(page.data.capacity.rows >= rows); // todo: handle this page.data.size.rows = rows; var page_list: List = .{}; page_list.prepend(page); - // for (0..1) |_| { - // const p = try pool.create(); - // p.* = .{ - // .data = try Page.init(alloc, .{ - // .cols = cols, - // .rows = @max(rows, page_min_rows), - // .styles = page_default_styles, - // }), - // }; - // p.data.size.rows = 0; - // page_list.append(p); - // } - return .{ .alloc = alloc, .cols = cols, diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 4d899a1fdf..7f65526753 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -246,5 +246,5 @@ test "Screen read and write" { try s.testWriteString("hello, world"); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); - //try testing.expectEqualStrings("hello, world", str); + try testing.expectEqualStrings("hello, world", str); } diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index cac9fa6abc..312dafc16d 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -1,6 +1,6 @@ const builtin = @import("builtin"); -const page = @import("page.zig"); +pub const page = @import("page.zig"); pub const PageList = @import("PageList.zig"); pub const Terminal = @import("Terminal.zig"); pub const Page = page.Page; From 21c6026922c23c34d1201eec77db80732eabb8d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 13:17:52 -0800 Subject: [PATCH 046/428] terminal/new: pagelist doesn't actively maintain active offset --- src/terminal/new/PageList.zig | 40 +++++++++++++++++++++++++---------- src/terminal/new/Screen.zig | 19 +++++++---------- src/terminal/new/Terminal.zig | 11 +++++----- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 2f3d407dc9..11a6cf6eb1 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -57,7 +57,6 @@ pages: List, /// - history: active row minus one /// viewport: Viewport, -active: RowOffset, /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily @@ -120,7 +119,6 @@ pub fn init( .page_pool = page_pool, .pages = page_list, .viewport = .{ .active = {} }, - .active = .{ .page = page }, }; } @@ -224,10 +222,30 @@ pub fn rowIterator( /// Get the top-left of the screen for the given tag. fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { return switch (tag) { - .active => self.active, + // The full screen or history is always just the first page. .screen, .history => .{ .page = self.pages.first.? }, + .viewport => switch (self.viewport) { - .active => self.active, + // If the viewport is in the active area then its the same as active. + .active => self.getTopLeft(.active), + }, + + // The active area is calculated backwards from the last page. + // This makes getting the active top left slower but makes scrolling + // much faster because we don't need to update the top left. Under + // heavy load this makes a measurable difference. + .active => active: { + var page = self.pages.last.?; + var rem = self.rows; + while (rem > page.data.size.rows) { + rem -= page.data.size.rows; + page = page.prev.?; // assertion: we always have enough rows for active + } + + break :active .{ + .page = page, + .row_offset = page.data.size.rows - rem, + }; }, }; } @@ -311,12 +329,12 @@ test "PageList" { var s = try init(alloc, 80, 24, 1000); defer s.deinit(); - - // Viewport is setup try testing.expect(s.viewport == .active); - try testing.expect(s.active.page == s.pages.first); - try testing.expect(s.active.page.next == null); - try testing.expect(s.active.row_offset == 0); - try testing.expect(s.active.page.data.size.cols == 80); - try testing.expect(s.active.page.data.size.rows == 24); + try testing.expect(s.pages.first != null); + + // Active area should be the top + try testing.expectEqual(RowOffset{ + .page = s.pages.first.?, + .row_offset = 0, + }, s.getTopLeft(.active)); } diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 7f65526753..12c0810b2d 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -51,13 +51,17 @@ pub fn init( rows: size.CellCountInt, max_scrollback: usize, ) !Screen { - // Initialize our backing pages. This will initialize the viewport. + // Initialize our backing pages. var pages = try PageList.init(alloc, cols, rows, max_scrollback); errdefer pages.deinit(); - // The viewport is guaranteed to exist, so grab it so we can setup - // our initial cursor. - const page_offset = pages.rowOffset(.{ .active = .{ .x = 0, .y = 0 } }); + // The active area is guaranteed to be allocated and the first + // page in the list after init. This lets us quickly setup the cursor. + // This is MUCH faster than pages.rowOffset. + const page_offset: PageList.RowOffset = .{ + .page = pages.pages.first.?, + .row_offset = 0, + }; const page_rac = page_offset.rowAndCell(0); return .{ @@ -146,13 +150,6 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - - // try self.pages.scrollActive(1); - // const page_offset = self.pages.active.forward(self.cursor.y).?; - // const page_rac = page_offset.rowAndCell(self.cursor.x); - // self.cursor.page_offset = page_offset; - // self.cursor.page_row = page_rac.row; - // self.cursor.page_cell = page_rac.cell; } /// Dump the screen to a string. The writer given should be buffered; diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 6f5af80f99..a7ff10a44d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -461,12 +461,11 @@ test "Terminal: input that forces scroll" { 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); - // TODO once viewport is moved - // { - // const str = try t.plainString(alloc); - // defer alloc.free(str); - // try testing.expectEqualStrings("b\nc\nd\ne\nf", str); - // } + { + 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" { From 587289662f523e76fb8b70d7b4961aeb363ae8e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 16:33:16 -0800 Subject: [PATCH 047/428] terminal/new: wide char support --- src/terminal/new/PageList.zig | 47 ++++++- src/terminal/new/Screen.zig | 72 ++++++++++- src/terminal/new/Terminal.zig | 231 +++++++++++++++++++++++++++++----- src/terminal/new/page.zig | 23 +++- 4 files changed, 332 insertions(+), 41 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 11a6cf6eb1..02823f63a7 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -176,15 +176,17 @@ pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { /// Get the cell at the given point, or null if the cell does not /// exist or is out of bounds. +/// +/// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { - const row = self.getTopLeft(pt).forward(pt.y) orelse return null; - const rac = row.page.data.getRowAndCell(row.row_offset, pt.x); + const row = self.getTopLeft(pt).forward(pt.coord().y) orelse return null; + const rac = row.page.data.getRowAndCell(pt.coord().x, row.row_offset); return .{ .page = row.page, .row = rac.row, .cell = rac.cell, .row_idx = row.row_offset, - .col_idx = pt.x, + .col_idx = pt.coord().x, }; } @@ -282,6 +284,14 @@ pub const RowOffset = struct { }; } + /// TODO: docs + pub fn backward(self: RowOffset, idx: usize) ?RowOffset { + return switch (self.backwardOverflow(idx)) { + .offset => |v| v, + .overflow => null, + }; + } + /// Move the offset forward n rows. If the offset goes beyond the /// end of the screen, return the overflow amount. fn forwardOverflow(self: RowOffset, n: usize) union(enum) { @@ -313,6 +323,37 @@ pub const RowOffset = struct { n_left -= page.data.size.rows; } } + + /// Move the offset backward n rows. If the offset goes beyond the + /// start of the screen, return the overflow amount. + fn backwardOverflow(self: RowOffset, n: usize) union(enum) { + offset: RowOffset, + overflow: struct { + end: RowOffset, + remaining: usize, + }, + } { + // Index fits within this page + if (n >= self.row_offset) return .{ .offset = .{ + .page = self.page, + .row_offset = self.row_offset - n, + } }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var n_left: usize = n - self.row_offset; + while (true) { + page = page.prev orelse return .{ .overflow = .{ + .end = .{ .page = page, .row_offset = 0 }, + .remaining = n_left, + } }; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .row_offset = page.data.size.rows - n_left, + } }; + n_left -= page.data.size.rows; + } + } }; const Cell = struct { diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 12c0810b2d..b2884555e8 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -81,14 +81,57 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } +pub fn cursorCellRight(self: *Screen) *pagepkg.Cell { + assert(self.cursor.x + 1 < self.pages.cols); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell + 1); +} + +pub fn cursorCellLeft(self: *Screen) *pagepkg.Cell { + assert(self.cursor.x > 0); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell - 1); +} + +pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { + assert(self.cursor.y > 0); + + const page_offset = self.cursor.page_offset.backward(1).?; + const page_rac = page_offset.rowAndCell(self.pages.cols - 1); + 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) void { - assert(self.cursor.x + 1 < self.pages.cols); +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.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 + 1); - self.cursor.x += 1; + self.cursor.page_cell = @ptrCast(cell - 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) void { + assert(self.cursor.y > 0); + + const page_offset = self.cursor.page_offset.backward(1).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.y -= 1; } /// Move the cursor down. @@ -182,9 +225,26 @@ pub fn dumpString( // TODO: handle wrap blank_rows += 1; + var blank_cells: usize = 0; for (cells) |cell| { - // TODO: handle blanks between chars - if (cell.codepoint == 0) break; + // 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.codepoint == 0) { + blank_cells += 1; + continue; + } + if (blank_cells > 0) { + for (0..blank_cells) |_| try writer.writeByte(' '); + blank_cells = 0; + } + try writer.print("{u}", .{cell.codepoint}); } } diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index a7ff10a44d..ec307c8a2f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -265,13 +265,34 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell - 1 => @call(.always_inline, printCell, .{ self, c }), + 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 => @panic("TODO: wide characters"), + 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, } @@ -284,47 +305,67 @@ pub fn print(self: *Terminal, c: u21) !void { } // Move the cursor - self.screen.cursorRight(); + self.screen.cursorRight(1); } -fn printCell(self: *Terminal, unmapped_c: u21) void { +fn printCell( + self: *Terminal, + unmapped_c: u21, + wide: Cell.Wide, +) void { // TODO: charsets const c: u21 = unmapped_c; - // 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(); - // } - // } + // TODO: prev cell overwriting style, dec refs, etc. + 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(); + 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(); + 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.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); - - // TODO: prev cell overwriting style + if (cell.grapheme) @panic("TODO: clear graphemes"); // Write self.screen.cursor.page_cell.* = .{ .style_id = self.screen.cursor.style_id, .codepoint = c, + .wide = wide, }; // If we have non-default style then we need to update the ref count. @@ -411,6 +452,60 @@ pub fn index(self: *Terminal) !void { } } +// 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; + } + + @panic("TODO: y change"); + // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); + +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -489,3 +584,77 @@ test "Terminal: print single very long line" { // 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.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 over wide char at 0,0" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + t.setCursorPos(0, 0); + try t.print('A'); // Smiley face + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), 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, 'A'), cell.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, 0), cell.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print over wide spacer tail" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + try t.print('橋'); + 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.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.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); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 3971e3d223..f853dae4b1 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -369,7 +369,28 @@ pub const Cell = packed struct(u64) { /// map for this cell to build a multi-codepoint grapheme. grapheme: bool = false, - _padding: u26 = 0, + /// The wide property of this cell, for wide characters. Characters in + /// a terminal grid can only be 1 or 2 cells wide. A wide character + /// is always next to a spacer. This is used to determine both the width + /// and spacer properties of a cell. + wide: Wide = .narrow, + + _padding: u24 = 0, + + pub const Wide = enum(u2) { + /// Not a wide character, cell width 1. + narrow = 0, + + /// Wide character, cell width 2. + wide = 1, + + /// Spacer after wide character. Do not render. + spacer_tail = 2, + + /// Spacer at the end of a soft-wrapped line to indicate that a wide + /// character is continued on the next line. + spacer_head = 3, + }; /// Returns true if the set of cells has text in it. pub fn hasText(cells: []const Cell) bool { From d95bde0af77ad5449029f6201d0de4f27c07bd32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 17:05:28 -0800 Subject: [PATCH 048/428] terminal/new: port many more tests --- src/terminal/Terminal.zig | 10 ++ src/terminal/new/Terminal.zig | 169 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 723c8d97b5..26bccde42e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2232,6 +2232,7 @@ test "Terminal: fullReset status display" { 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); @@ -2247,6 +2248,7 @@ test "Terminal: input with no control characters" { } } +// X test "Terminal: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2260,6 +2262,7 @@ 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); @@ -2269,6 +2272,7 @@ test "Terminal: print single very long line" { 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); @@ -2293,6 +2297,7 @@ test "Terminal: print over wide char at 0,0" { } } +// X test "Terminal: print over wide spacer tail" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2611,6 +2616,7 @@ test "Terminal: print invalid VS16 with second char" { } } +// X test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); @@ -2643,6 +2649,7 @@ test "Terminal: soft wrap with semantic 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); @@ -2671,6 +2678,7 @@ test "Terminal: disabled wraparound with wide char and one space" { } } +// X test "Terminal: disabled wraparound with wide char and no space" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2848,6 +2856,7 @@ test "Terminal: print invoke charset single" { } } +// X test "Terminal: print right margin wrap" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -2865,6 +2874,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index ec307c8a2f..d441c37e64 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -195,6 +195,23 @@ 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' => { + @panic("TODO: newline"); + // 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 }); @@ -506,6 +523,20 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { } +/// 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); +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -606,6 +637,23 @@ test "Terminal: print wide char" { } } +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.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: print over wide char at 0,0" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -658,3 +706,124 @@ test "Terminal: print over wide spacer tail" { try testing.expectEqualStrings(" X", str); } } + +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: 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.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.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); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1234X6789\n Y", str); + } +} + +test "Terminal: print right margin outside" { + var t = try init(testing.allocator, 10, 5); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 6); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("12345XY89", str); + } +} + +test "Terminal: print right margin outside wrap" { + var t = try init(testing.allocator, 10, 5); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 10); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456789X\n Y", str); + } +} From 4acbf09bb6e8a70f9a850d28eb05c15316fa30ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 17:09:21 -0800 Subject: [PATCH 049/428] terminal/new: CR and LF --- src/terminal/Terminal.zig | 8 +++ src/terminal/new/Terminal.zig | 112 +++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 26bccde42e..8ceebc2899 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2892,6 +2892,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); @@ -2909,6 +2910,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); @@ -2927,6 +2929,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); @@ -2938,6 +2941,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); @@ -2954,6 +2958,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); @@ -2965,6 +2970,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); @@ -2976,6 +2982,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); @@ -2986,6 +2993,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index d441c37e64..07f400978c 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -202,9 +202,8 @@ pub fn printString(self: *Terminal, str: []const u8) !void { while (it.nextCodepoint()) |cp| { switch (cp) { '\n' => { - @panic("TODO: newline"); - // self.carriageReturn(); - // try self.linefeed(); + self.carriageReturn(); + try self.linefeed(); }, else => try self.print(cp), @@ -411,6 +410,26 @@ fn printWrap(self: *Terminal) !void { self.screen.cursor.page_row.flags.wrap_continuation = true; } +/// 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(); +} + /// 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 @@ -827,3 +846,90 @@ test "Terminal: print right margin outside wrap" { try testing.expectEqualStrings("123456789X\n Y", str); } } + +test "Terminal: linefeed and carriage return" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("world") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), 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); + try testing.expectEqualStrings("hello\nworld", str); + } +} + +test "Terminal: linefeed unsets pending wrap" { + var t = try init(testing.allocator, 5, 80); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap == true); + try t.linefeed(); + try testing.expect(t.screen.cursor.pending_wrap == false); +} + +test "Terminal: linefeed mode automatic carriage return" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); + + // Basic grid writing + t.modes.set(.linefeed, true); + try t.printString("123456"); + try t.linefeed(); + try t.print('X'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456\nX", str); + } +} + +test "Terminal: carriage return unsets pending wrap" { + var t = try init(testing.allocator, 5, 80); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap == true); + t.carriageReturn(); + try testing.expect(t.screen.cursor.pending_wrap == false); +} + +test "Terminal: carriage return origin mode moves to left margin" { + var t = try init(testing.allocator, 5, 80); + defer t.deinit(testing.allocator); + + t.modes.set(.origin, true); + t.screen.cursor.x = 0; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.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); + + t.screen.cursor.x = 1; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.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); + + t.screen.cursor.x = 3; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); +} From dd7bb1fab5f522fff4f6b6ba62d43c0bdd718c43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 17:16:23 -0800 Subject: [PATCH 050/428] terminal/new: backspace, cursor left --- src/terminal/Terminal.zig | 1 + src/terminal/new/Screen.zig | 21 +++++- src/terminal/new/Terminal.zig | 121 ++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8ceebc2899..66d0d5ba72 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3004,6 +3004,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); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index b2884555e8..7857b252c7 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -152,7 +152,7 @@ pub fn cursorDown(self: *Screen) void { self.cursor.y += 1; } -/// Move the cursor to some absolute position. +/// Move the cursor to some absolute horizontal position. pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { assert(x < self.pages.cols); @@ -161,6 +161,25 @@ pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { 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); + + const page_offset = if (y < self.cursor.y) + self.cursor.page_offset.backward(self.cursor.y - y).? + else if (y > self.cursor.y) + self.cursor.page_offset.forward(y - self.cursor.y).? + else + self.cursor.page_offset; + const page_rac = page_offset.rowAndCell(x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; + self.cursor.y = y; +} + /// 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 { diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 07f400978c..be2d5ce814 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -430,6 +430,110 @@ pub fn linefeed(self: *Terminal) !void { 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 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: size.CellCountInt = @intCast(@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(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.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) { + if (!self.screen.cursor.page_row.flags.wrap) break; + } + + self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); + count -= 1; + } +} + /// 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 @@ -933,3 +1037,20 @@ test "Terminal: carriage return right of left margin moves to left margin" { t.carriageReturn(); try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } + +test "Terminal: backspace" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // BS + for ("hello") |c| try t.print(c); + t.backspace(); + try t.print('y'); + 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); + try testing.expectEqualStrings("helly", str); + } +} From d87a1c694e15f4219a1c27fdd951170603d4e163 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 17:27:16 -0800 Subject: [PATCH 051/428] terminal/new: setcursorpos tests --- src/terminal/Terminal.zig | 12 ++ src/terminal/new/Terminal.zig | 337 +++++++++++++++++++++++++++++++++- 2 files changed, 348 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 66d0d5ba72..6ab2e3f65e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3022,6 +3022,7 @@ test "Terminal: backspace" { } } +// X test "Terminal: horizontal tabs" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3043,6 +3044,7 @@ 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); @@ -3061,6 +3063,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); @@ -3080,6 +3083,7 @@ test "Terminal: horizontal tabs with right margin" { } } +// X test "Terminal: horizontal tabs back" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3103,6 +3107,7 @@ 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); @@ -3121,6 +3126,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); @@ -3161,6 +3167,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); @@ -3179,6 +3186,7 @@ test "Terminal: cursorPos resets wrap" { } } +// X test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3194,6 +3202,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); @@ -3212,6 +3221,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); @@ -3232,6 +3242,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); @@ -3252,6 +3263,7 @@ test "Terminal: cursorPos limits with full scroll region" { } } +// X test "Terminal: setCursorPos (original test)" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index be2d5ce814..63cf3f6c90 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -534,6 +534,55 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { } } +/// 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 @@ -641,9 +690,31 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { return; } - @panic("TODO: y change"); + // 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 @@ -1054,3 +1125,267 @@ test "Terminal: backspace" { try testing.expectEqualStrings("helly", str); } } + +test "Terminal: horizontal tabs" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); + + // HT + try t.print('1'); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + + // HT + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + + // HT at the end + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.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); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y, 9); + try t.horizontalTab(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: horizontal tabs with right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); + + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(t.screen.cursor.y, 1); + try t.print('X'); + try t.horizontalTab(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X A", str); + } +} + +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); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.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); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y, 9); + try t.horizontalTabBack(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A X", str); + } +} + +test "Terminal: horizontal tabs with left margin in origin mode" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(1, 2); + try t.print('X'); + try t.horizontalTabBack(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" AX", str); + } +} + +test "Terminal: cursorPos 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.setCursorPos(1, 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("XBCDE", str); + } +} + +test "Terminal: cursorPos off the screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(500, 500); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n\n X", str); + } +} + +test "Terminal: cursorPos relative to origin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.modes.set(.origin, true); + t.setCursorPos(1, 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: cursorPos relative to origin with left/right" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n X", str); + } +} + +test "Terminal: cursorPos limits with full scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(500, 500); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n X", str); + } +} + +// 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); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + // Setting it to 0 should keep it zero (1 based) + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + // Should clamp to size + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + + // Should reset pending wrap + t.setCursorPos(0, 80); + try t.print('c'); + try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(0, 80); + try testing.expect(!t.screen.cursor.pending_wrap); + + // Origin mode + t.modes.set(.origin, true); + + // No change without a scroll region + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + + // Set the scroll region + // TODO + // t.setTopAndBottomMargin(10, t.rows); + // t.setCursorPos(0, 0); + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + // + // t.setCursorPos(1, 1); + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + // + // t.setCursorPos(100, 0); + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + // + // t.setTopAndBottomMargin(10, 11); + // t.setCursorPos(2, 0); + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); +} From c2ec97b804c416d1c67fce0cdddbbaf64a4867eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 19:56:12 -0800 Subject: [PATCH 052/428] terminal/new: hashmap k/v offsets were off the wrong base --- src/terminal/new/hash_map.zig | 7 ++++--- src/terminal/new/size.zig | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/hash_map.zig b/src/terminal/new/hash_map.zig index a6433260bb..c9c6507845 100644 --- a/src/terminal/new/hash_map.zig +++ b/src/terminal/new/hash_map.zig @@ -291,15 +291,16 @@ fn HashMapUnmanaged( assert(@intFromPtr(buf.start()) % base_align == 0); // Get all our main pointers - const metadata_ptr: [*]Metadata = @ptrCast(buf.start() + @sizeOf(Header)); + const metadata_buf = buf.rebase(@sizeOf(Header)); + const metadata_ptr: [*]Metadata = @ptrCast(metadata_buf.start()); // Build our map var map: Self = .{ .metadata = metadata_ptr }; const hdr = map.header(); hdr.capacity = layout.capacity; hdr.size = 0; - if (@sizeOf([*]K) != 0) hdr.keys = buf.member(K, layout.keys_start); - if (@sizeOf([*]V) != 0) hdr.values = buf.member(V, layout.vals_start); + if (@sizeOf([*]K) != 0) hdr.keys = metadata_buf.member(K, layout.keys_start); + if (@sizeOf([*]V) != 0) hdr.values = metadata_buf.member(V, layout.vals_start); map.initMetadatas(); return map; diff --git a/src/terminal/new/size.zig b/src/terminal/new/size.zig index b74316c8a9..fead2b469f 100644 --- a/src/terminal/new/size.zig +++ b/src/terminal/new/size.zig @@ -105,6 +105,15 @@ pub const OffsetBuf = struct { .offset = self.offset + offset, }; } + + /// Rebase the offset to have a zero offset by rebasing onto start. + /// This is similar to `add` but all of the offsets are merged into base. + pub fn rebase(self: OffsetBuf, offset: usize) OffsetBuf { + return .{ + .base = self.start() + offset, + .offset = 0, + }; + } }; /// Get the offset for a given type from some base pointer to the From de0eb859dfbe5453fe2b732b714e4b8f4ee77a5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 20:09:47 -0800 Subject: [PATCH 053/428] terminal/new: append/lookup graphemes and tests --- src/terminal/new/page.zig | 116 +++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index f853dae4b1..96d678f809 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -20,7 +20,8 @@ const alignForward = std.mem.alignForward; /// is that most skin-tone emoji are <= 4 codepoints, letter combiners /// are usually <= 4 codepoints, and 4 codepoints is a nice power of two /// for alignment. -const grapheme_chunk = 4 * @sizeOf(u21); +const grapheme_chunk_len = 4; +const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; @@ -201,6 +202,72 @@ pub const Page = struct { return .{ .row = row, .cell = cell }; } + /// Append a codepoint to the given cell as a grapheme. + pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) !void { + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + + // If this cell has no graphemes, we can go faster by knowing we + // need to allocate a new grapheme slice and update the map. + if (!cell.grapheme) { + const cps = try self.grapheme_alloc.alloc(u21, self.memory, 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + cps[0] = cp; + + try map.putNoClobber(cell_offset, .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = 1, + }); + errdefer map.remove(cell_offset); + + cell.grapheme = true; + row.grapheme = true; + + return; + } + + // The cell already has graphemes. We need to append to the existing + // grapheme slice and update the map. + assert(row.grapheme); + + const slice = map.getPtr(cell_offset).?; + + // If our slice len doesn't divide evenly by the grapheme chunk + // length then we can utilize the additional chunk space. + if (slice.len % grapheme_chunk_len != 0) { + const cps = slice.offset.ptr(self.memory); + cps[slice.len] = cp; + slice.len += 1; + return; + } + + // We are out of chunk space. There is no fast path here. We need + // to allocate a larger chunk. This is a very slow path. We expect + // most graphemes to fit within our chunk size. + const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + const old_cps = slice.offset.ptr(self.memory)[0..slice.len]; + @memcpy(cps[0..old_cps.len], old_cps); + cps[slice.len] = cp; + slice.* = .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = slice.len + 1, + }; + + // Free our old chunk + self.grapheme_alloc.free(self.memory, old_cps); + } + + /// Returns the codepoints for the given cell. These are the codepoints + /// in addition to the first codepoint. The first codepoint is NOT + /// included since it is on the cell itself. + pub fn lookupGrapheme(self: *const Page, cell: *Cell) ?[]u21 { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.grapheme_map.map(self.memory); + const slice = map.get(cell_offset) orelse return null; + return slice.offset.ptr(self.memory)[0..slice.len]; + } + pub const Layout = struct { total_size: usize, rows_start: usize, @@ -490,3 +557,50 @@ test "Page read and write cells" { try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.codepoint); } } + +test "Page appendGrapheme small" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.codepoint = 0x09; + + // One + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.grapheme); + try testing.expectEqualSlices(u21, &.{0x0A}, page.lookupGrapheme(rac.cell).?); + + // Two + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.grapheme); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); +} + +test "Page appendGrapheme larger than chunk" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.codepoint = 0x09; + + const count = grapheme_chunk_len * 10; + for (0..count) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0A + i)); + } + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, count), cps.len); + for (0..count) |i| { + try testing.expectEqual(@as(u21, @intCast(0x0A + i)), cps[i]); + } +} From 26b1a00380386da939e0b1955bda87c02ad3d0ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 21:26:06 -0800 Subject: [PATCH 054/428] terminal/new: non-grapheme zwjs --- src/terminal/Terminal.zig | 3 + src/terminal/new/Screen.zig | 15 +++- src/terminal/new/Terminal.zig | 154 ++++++++++++++++++++++++++++++++-- src/terminal/new/page.zig | 33 ++++---- 4 files changed, 178 insertions(+), 27 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6ab2e3f65e..d32051be01 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2410,6 +2410,7 @@ test "Terminal: VS16 repeated with mode 2027" { } } +// X test "Terminal: VS16 doesn't make character with 2027 disabled" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2435,6 +2436,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } } +// X test "Terminal: print multicodepoint grapheme, disabled mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2526,6 +2528,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +// X test "Terminal: print invalid VS16 non-grapheme" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 7857b252c7..43379a57f1 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -87,10 +87,10 @@ pub fn cursorCellRight(self: *Screen) *pagepkg.Cell { return @ptrCast(cell + 1); } -pub fn cursorCellLeft(self: *Screen) *pagepkg.Cell { - assert(self.cursor.x > 0); +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 - 1); + return @ptrCast(cell - n); } pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { @@ -245,7 +245,7 @@ pub fn dumpString( blank_rows += 1; var blank_cells: usize = 0; - for (cells) |cell| { + for (cells) |*cell| { // Skip spacers switch (cell.wide) { .narrow, .wide => {}, @@ -265,6 +265,13 @@ pub fn dumpString( } try writer.print("{u}", .{cell.codepoint}); + + if (cell.grapheme) { + const cps = row_offset.page.data.lookupGrapheme(cell).?; + for (cps) |cp| { + try writer.print("{u}", .{cp}); + } + } } } } diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 63cf3f6c90..da5f63c13a 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -259,7 +259,26 @@ pub fn print(self: *Terminal, c: u21) !void { return; } - @panic("TODO: zero-width characters"); + // 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 this is a emoji variation selector, prev must be an emoji + if (c == 0xFE0F or c == 0xFE0E) { + const prev_props = unicode.getProperties(prev.codepoint); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + } + + try self.screen.cursor.page_offset.page.data.appendGrapheme( + self.screen.cursor.page_row, + prev, + c, + ); + return; } // We have a printable character, save it @@ -359,7 +378,7 @@ fn printCell( .spacer_tail => { assert(self.screen.cursor.x > 0); - const wide_cell = self.screen.cursorCellLeft(); + 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(); @@ -391,7 +410,7 @@ fn printCell( } fn printWrap(self: *Terminal) !void { - self.screen.cursor.page_row.flags.wrap = true; + 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 @@ -407,7 +426,7 @@ fn printWrap(self: *Terminal) !void { // 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); - self.screen.cursor.page_row.flags.wrap_continuation = true; + self.screen.cursor.page_row.wrap_continuation = true; } /// Carriage return moves the cursor to the first column. @@ -526,7 +545,7 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // If our previous line is not wrapped then we are done. if (wrap_mode != .reverse_extended) { - if (!self.screen.cursor.page_row.flags.wrap) break; + if (!self.screen.cursor.page_row.wrap) break; } self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); @@ -901,6 +920,131 @@ test "Terminal: print over wide spacer tail" { } } +test "Terminal: print multicodepoint grapheme, disabled mode 2027" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // https://github.com/mitchellh/ghostty/issues/289 + // 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 6 cells taken up + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 6), 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, 0x1F468), cell.codepoint); + try testing.expect(cell.grapheme); + 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 = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F469), cell.codepoint); + try testing.expect(cell.grapheme); + 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 = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F467), cell.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.codepoint); + try testing.expect(!cell.grapheme); + 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.codepoint); + try testing.expect(cell.grapheme); + 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.codepoint); + try testing.expect(!cell.grapheme); + 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.codepoint); + } +} + test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 96d678f809..ef696adec3 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -395,27 +395,24 @@ pub const Capacity = struct { }; pub const Row = packed struct(u64) { - _padding: u29 = 0, - /// The cells in the row offset from the page. cells: Offset(Cell), - /// Flags where we want to pack bits - flags: packed struct { - /// True if this row is soft-wrapped. The first cell of the next - /// row is a continuation of this row. - wrap: bool = false, - - /// True if the previous row to this one is soft-wrapped and - /// this row is a continuation of that row. - wrap_continuation: bool = false, - - /// True if any of the cells in this row have multi-codepoint - /// grapheme clusters. If this is true, some fast paths are not - /// possible because erasing for example may need to clear existing - /// grapheme data. - grapheme: bool = false, - } = .{}, + /// True if this row is soft-wrapped. The first cell of the next + /// row is a continuation of this row. + wrap: bool = false, + + /// True if the previous row to this one is soft-wrapped and + /// this row is a continuation of that row. + wrap_continuation: bool = false, + + /// True if any of the cells in this row have multi-codepoint + /// grapheme clusters. If this is true, some fast paths are not + /// possible because erasing for example may need to clear existing + /// grapheme data. + grapheme: bool = false, + + _padding: u29 = 0, }; /// A cell represents a single terminal grid cell. From 24dab9d01e1f8f3f894e26fc9c6ff679a07c9c37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 21:53:56 -0800 Subject: [PATCH 055/428] terminal/new: graphemes --- src/terminal/Terminal.zig | 7 + src/terminal/new/Terminal.zig | 350 +++++++++++++++++++++++++++++++++- 2 files changed, 355 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d32051be01..2e966ce037 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2327,6 +2327,7 @@ test "Terminal: print over wide spacer tail" { } } +// X test "Terminal: VS15 to make narrow character" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2352,6 +2353,7 @@ test "Terminal: VS15 to make narrow character" { } } +// X test "Terminal: VS16 to make wide character with mode 2027" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2377,6 +2379,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { } } +// X test "Terminal: VS16 repeated with mode 2027" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2492,6 +2495,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { } } +// X test "Terminal: print multicodepoint grapheme, mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2556,6 +2560,7 @@ test "Terminal: print invalid VS16 non-grapheme" { } } +// X test "Terminal: print invalid VS16 grapheme" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2586,6 +2591,7 @@ test "Terminal: print invalid VS16 grapheme" { } } +// X test "Terminal: print invalid VS16 with second char" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2710,6 +2716,7 @@ test "Terminal: disabled wraparound with wide char and no space" { } } +// X test "Terminal: disabled wraparound with wide grapheme and half space" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index da5f63c13a..b304409314 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -227,8 +227,127 @@ pub fn print(self: *Terminal, c: u21) !void { // 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) { - @panic("TODO: graphemes"); + 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.codepoint == 0); + }; + + // 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.codepoint == 0) break :grapheme; + + const grapheme_break = brk: { + var state: unicode.GraphemeBreakState = .{}; + var cp1: u21 = prev.cell.codepoint; + if (prev.cell.grapheme) { + const cps = self.screen.cursor.page_offset.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.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.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_offset.page.data.appendGrapheme( + self.screen.cursor.page_row, + prev.cell, + c, + ); + return; + } } // Determine the width of this character so we can handle @@ -1045,6 +1164,204 @@ test "Terminal: print invalid VS16 non-grapheme" { } } +test "Terminal: print multicodepoint grapheme, mode 2027" { + 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/289 + // 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 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, 2), 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, 0x1F468), cell.codepoint); + try testing.expect(cell.grapheme); + 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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x26C8), cell.codepoint); + try testing.expect(cell.grapheme); + 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.codepoint); + try testing.expect(cell.grapheme); + 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.codepoint); + try testing.expect(cell.grapheme); + 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.codepoint); + try testing.expect(cell.grapheme); + 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: print invalid VS16 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); + + // 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.codepoint); + try testing.expect(!cell.grapheme); + 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.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print invalid VS16 with second char" { + 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('y'); + + // 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, 2), 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.codepoint); + try testing.expect(!cell.grapheme); + 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, 'y'), cell.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); @@ -1115,6 +1432,35 @@ test "Terminal: disabled wraparound with wide char and no space" { } } +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.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); From 73f07725da9f470d1fa6376c23b5328c9fbad5fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 22:08:29 -0800 Subject: [PATCH 056/428] terminal/new: adjust grapheme bytes default up --- src/terminal/new/page.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index ef696adec3..e460553dd1 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -330,10 +330,10 @@ pub const Page = struct { /// requirements. This is enough to support a very large number of cells. /// The standard capacity is chosen as the fast-path for allocation. pub const std_capacity: Capacity = .{ - .cols = 250, - .rows = 250, + .cols = 215, + .rows = 215, .styles = 128, - .grapheme_bytes = 1024, + .grapheme_bytes = 8192, }; /// The size of this page. From eb3afae57eae06b00189096b7718afed7d0a7aa7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Feb 2024 22:26:59 -0800 Subject: [PATCH 057/428] terminal/new: clear graphemes on overwrite --- src/terminal/new/Terminal.zig | 36 ++++++++++++++++++++++-- src/terminal/new/page.zig | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b304409314..ad423921fa 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -513,10 +513,15 @@ fn printCell( } // If the prior value had graphemes, clear those - if (cell.grapheme) @panic("TODO: clear graphemes"); + if (cell.grapheme) { + self.screen.cursor.page_offset.page.data.clearGrapheme( + self.screen.cursor.page_row, + cell, + ); + } // Write - self.screen.cursor.page_cell.* = .{ + cell.* = .{ .style_id = self.screen.cursor.style_id, .codepoint = c, .wide = wide, @@ -1362,6 +1367,33 @@ test "Terminal: print invalid VS16 with second char" { } } +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); + + 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("A", 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.codepoint); + try testing.expect(!cell.grapheme); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index e460553dd1..95456f67b9 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -268,6 +268,30 @@ pub const Page = struct { return slice.offset.ptr(self.memory)[0..slice.len]; } + /// Clear the graphemes for a given cell. + pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { + assert(cell.grapheme); + + // Get our entry in the map, which must exist + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(cell_offset).?; + + // Free our grapheme data + const cps = entry.value_ptr.offset.ptr(self.memory)[0..entry.value_ptr.len]; + self.grapheme_alloc.free(self.memory, cps); + + // Remove the entry + map.removeByPtr(entry.key_ptr); + + // Mark that we no longer have graphemes, also search the row + // to make sure its state is correct. + cell.grapheme = false; + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.grapheme) return; + row.grapheme = false; + } + pub const Layout = struct { total_size: usize, rows_start: usize, @@ -577,6 +601,11 @@ test "Page appendGrapheme small" { try testing.expect(rac.row.grapheme); try testing.expect(rac.cell.grapheme); try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); + + // Clear it + page.clearGrapheme(rac.row, rac.cell); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.grapheme); } test "Page appendGrapheme larger than chunk" { @@ -601,3 +630,26 @@ test "Page appendGrapheme larger than chunk" { try testing.expectEqual(@as(u21, @intCast(0x0A + i)), cps[i]); } } + +test "Page clearGrapheme not all cells" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.codepoint = 0x09; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + + const rac2 = page.getRowAndCell(1, 0); + rac2.cell.codepoint = 0x09; + try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); + + // Clear it + page.clearGrapheme(rac.row, rac.cell); + try testing.expect(rac.row.grapheme); + try testing.expect(!rac.cell.grapheme); + try testing.expect(rac2.cell.grapheme); +} From 4c374d79770ef96f137fe61acda1a8c574640b5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Feb 2024 21:16:56 -0800 Subject: [PATCH 058/428] terminal/new: PageList scrolling --- src/terminal/new/PageList.zig | 170 +++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 02823f63a7..be9e305be3 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -70,6 +70,10 @@ pub const Viewport = union(enum) { /// for this instead of tracking the row offset, we eliminate a number of /// memory writes making scrolling faster. active, + + /// The viewport is pinned to the top of the screen, or the farthest + /// back in the scrollback history. + top, }; /// Initialize the page. The top of the first page in the list is always the @@ -129,6 +133,31 @@ pub fn deinit(self: *PageList) void { self.pool.deinit(); } +/// Scroll options. +pub const Scroll = union(enum) { + /// Scroll to the active area. This is also sometimes referred to as + /// the "bottom" of the screen. This makes it so that the end of the + /// screen is fully visible since the active area is the bottom + /// rows/cols of the screen. + active, + + /// Scroll to the top of the screen, which is the farthest back in + /// the scrollback history. + top, +}; + +/// Scroll the viewport. This will never create new scrollback, allocate +/// pages, etc. This can only be used to move the viewport within the +/// previously allocated pages. +pub fn scroll(self: *PageList, behavior: Scroll) void { + switch (behavior) { + .active => self.viewport = .{ .active = {} }, + .top => self.viewport = .{ .top = {} }, + } +} + +/// Grow the page list by exactly one page and return the new page. The +/// newly allocated page will be size 0 (but capacity is set). pub fn grow(self: *PageList) !*List.Node { const next_page = try self.createPage(); // we don't errdefer this because we've added it to the linked @@ -228,8 +257,8 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { .screen, .history => .{ .page = self.pages.first.? }, .viewport => switch (self.viewport) { - // If the viewport is in the active area then its the same as active. .active => self.getTopLeft(.active), + .top => self.getTopLeft(.screen), }, // The active area is calculated backwards from the last page. @@ -252,6 +281,42 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { }; } +/// The total rows in the screen. This is the actual row count currently +/// and not a capacity or maximum. +/// +/// This is very slow, it traverses the full list of pages to count the +/// rows, so it is not pub. This is only used for testing/debugging. +fn totalRows(self: *const PageList) usize { + var rows: usize = 0; + var page = self.pages.first; + while (page) |p| { + rows += p.data.size.rows; + page = p.next; + } + + return rows; +} + +/// Grow the number of rows available in the page list by n. +/// This is only used for testing so it isn't optimized. +fn growRows(self: *PageList, n: usize) !void { + var page = self.pages.last.?; + var n_rem: usize = n; + if (page.data.size.rows < page.data.capacity.rows) { + const add = @min(n_rem, page.data.capacity.rows - page.data.size.rows); + page.data.size.rows += add; + if (n_rem == add) return; + n_rem -= add; + } + + while (n_rem > 0) { + page = try self.grow(); + const add = @min(n_rem, page.data.capacity.rows); + page.data.size.rows = add; + n_rem -= add; + } +} + /// Represents some y coordinate within the screen. Since pages can /// be split at any row boundary, getting some Y-coordinate within /// any part of the screen may map to a different page and row offset @@ -362,6 +427,28 @@ const Cell = struct { cell: *pagepkg.Cell, row_idx: usize, col_idx: usize, + + /// Gets the screen point for the given cell. + /// + /// This is REALLY expensive/slow so it isn't pub. This was built + /// for debugging and tests. If you have a need for this outside of + /// this file then consider a different approach and ask yourself very + /// carefully if you really need this. + fn screenPoint(self: Cell) point.Point { + var x: usize = self.col_idx; + var y: usize = self.row_idx; + var page = self.page; + while (page.prev) |prev| { + x += prev.data.size.cols; + y += prev.data.size.rows; + page = prev; + } + + return .{ .screen = .{ + .x = x, + .y = y, + } }; + } }; test "PageList" { @@ -372,6 +459,7 @@ test "PageList" { defer s.deinit(); try testing.expect(s.viewport == .active); try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); // Active area should be the top try testing.expectEqual(RowOffset{ @@ -379,3 +467,83 @@ test "PageList" { .row_offset = 0, }, s.getTopLeft(.active)); } + +test "PageList active after grow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + try s.growRows(10); + try testing.expectEqual(@as(usize, s.rows + 10), s.totalRows()); + + // Make sure all points make sense + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + { + const pt = s.getCell(.{ .screen = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } +} + +test "PageList scroll top" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .top = {} }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + s.scroll(.{ .active = {} }); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } +} From e8d548e8d0c2dfc27407b34094ca43f6b4136886 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Feb 2024 21:44:29 -0800 Subject: [PATCH 059/428] terminal/new: scroll by delta --- src/terminal/new/PageList.zig | 189 +++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index be9e305be3..f879df345b 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -74,6 +74,11 @@ pub const Viewport = union(enum) { /// The viewport is pinned to the top of the screen, or the farthest /// back in the scrollback history. top, + + /// The viewport is pinned to an exact row offset. If this page is + /// deleted (i.e. due to pruning scrollback), then the viewport will + /// stick to the top. + exact: RowOffset, }; /// Initialize the page. The top of the first page in the list is always the @@ -144,6 +149,10 @@ pub const Scroll = union(enum) { /// Scroll to the top of the screen, which is the farthest back in /// the scrollback history. top, + + /// Scroll up (negative) or down (positive) by the given number of + /// rows. This is clamped to the "top" and "active" top left. + delta_row: isize, }; /// Scroll the viewport. This will never create new scrollback, allocate @@ -153,6 +162,56 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { .active => self.viewport = .{ .active = {} }, .top => self.viewport = .{ .top = {} }, + .delta_row => |n| { + if (n == 0) return; + + const top = self.getTopLeft(.viewport); + const offset: RowOffset = if (n < 0) switch (top.backwardOverflow(@intCast(-n))) { + .offset => |v| v, + .overflow => |v| v.end, + } else forward: { + // Not super happy with the logic to scroll forward. I think + // this is pretty slow, but it is human-driven (scrolling + // this way) so hyper speed isn't strictly necessary. Still, + // it feels bad. + + const forward_offset = switch (top.forwardOverflow(@intCast(n))) { + .offset => |v| v, + .overflow => |v| v.end, + }; + + var final_offset: ?RowOffset = forward_offset; + + // Ensure we have at least rows rows in the viewport. There + // is probably a smarter way to do this. + var page = self.pages.last.?; + var rem = self.rows; + while (rem > page.data.size.rows) { + rem -= page.data.size.rows; + + // If we see our forward page here then we know its + // beyond the active area and we can set final null. + if (page == forward_offset.page) final_offset = null; + + page = page.prev.?; // assertion: we always have enough rows for active + } + const active_offset = .{ .page = page, .row_offset = page.data.size.rows - rem }; + + // If we have a final still and we're on the same page + // but the active area is before the forward area, then + // we can use the active area. + if (final_offset != null and + active_offset.page == forward_offset.page and + forward_offset.row_offset > active_offset.row_offset) + { + final_offset = active_offset; + } + + break :forward final_offset orelse active_offset; + }; + + self.viewport = .{ .exact = offset }; + }, } } @@ -259,6 +318,7 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { .viewport => switch (self.viewport) { .active => self.getTopLeft(.active), .top => self.getTopLeft(.screen), + .exact => |v| v, }, // The active area is calculated backwards from the last page. @@ -399,7 +459,7 @@ pub const RowOffset = struct { }, } { // Index fits within this page - if (n >= self.row_offset) return .{ .offset = .{ + if (n <= self.row_offset) return .{ .offset = .{ .page = self.page, .row_offset = self.row_offset - n, } }; @@ -547,3 +607,130 @@ test "PageList scroll top" { } }, pt); } } + +test "PageList scroll delta row back" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .delta_row = -1 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } +} + +test "PageList scroll delta row back overflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .delta_row = -100 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList scroll delta row forward" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .top = {} }); + s.scroll(.{ .delta_row = 2 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } +} + +test "PageList scroll delta row forward into active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 1000); + defer s.deinit(); + + s.scroll(.{ .delta_row = 2 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} From b053be0164c3ddd4dcde453fbc6452f44fdc33c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Feb 2024 21:50:52 -0800 Subject: [PATCH 060/428] terminal/new: scrolling viewport --- src/terminal/Terminal.zig | 1 + src/terminal/new/Screen.zig | 19 ++++++++++++++++++ src/terminal/new/Terminal.zig | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2e966ce037..7c129f1ede 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2747,6 +2747,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { } } +// X test "Terminal: print writes to bottom if scrolled" { var t = try init(testing.allocator, 5, 2); defer t.deinit(testing.allocator); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 43379a57f1..e9fe40b14b 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -214,6 +214,25 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_cell = page_rac.cell; } +/// 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 { + switch (behavior) { + .active => self.pages.scroll(.{ .active = {} }), + .top => self.pages.scroll(.{ .top = {} }), + .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), + } +} + /// 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. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index ad423921fa..b19df6a68d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1394,6 +1394,43 @@ test "Terminal: overwrite grapheme should clear grapheme data" { } } +test "Terminal: print writes to bottom if scrolled" { + var t = try init(testing.allocator, 5, 2); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + t.setCursorPos(0, 0); + + // Make newlines so we create scrollback + // 3 pushes hello off the screen + try t.index(); + try t.index(); + try t.index(); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to the top + t.screen.scroll(.{ .top = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Type + try t.print('A'); + t.screen.scroll(.{ .active = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } +} + test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); From 6fb4fddedfac0b4240672e6d110f7101214b2fc1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 17:29:17 -0800 Subject: [PATCH 061/428] terminal/new: insertLines --- src/terminal/Terminal.zig | 6 + src/terminal/new/Terminal.zig | 230 ++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 7c129f1ede..0bbdc21a1f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3802,6 +3802,7 @@ test "Terminal: deleteLines left/right scroll region high count" { } } +// X test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3824,6 +3825,7 @@ test "Terminal: insertLines simple" { } } +// X test "Terminal: insertLines outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3897,6 +3899,7 @@ test "Terminal: insertLines left/right scroll region" { } } +// X test "Terminal: insertLines" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -3930,6 +3933,7 @@ test "Terminal: insertLines" { } } +// X test "Terminal: insertLines zero" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -3973,6 +3977,7 @@ test "Terminal: insertLines with scroll region" { } } +// X test "Terminal: insertLines more than remaining" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4006,6 +4011,7 @@ test "Terminal: insertLines more than remaining" { } } +// X test "Terminal: insertLines resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b19df6a68d..31dc761882 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -874,6 +874,97 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) self.setCursorPos(1, 1); } +/// 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; + + // TODO + if (self.scrolling_region.left > 0 or self.scrolling_region.right < self.cols - 1) { + @panic("TODO: left and right margin mode"); + } + + // 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); + + // 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; + + // 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 + scroll_amount; + + // TODO: detect active area split across multiple pages + + // 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); + + // 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_cells = dst.cells; + dst.cells = src.cells; + src.cells = dst_cells; + + // TODO: grapheme data for dst_cells should be deleted + // TODO: grapheme data for src.cells needs to be moved + } + + for (0..adjusted_count) |i| { + const row: *Row = @ptrCast(top + i); + + // Clear the src row. + // TODO: cells should keep bg style of pen + // TODO: grapheme needs to be deleted + const cells = self.screen.cursor.page_offset.page.data.getCells(row); + @memset(cells, .{}); + } + + // 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; +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -1948,3 +2039,142 @@ test "Terminal: setCursorPos (original test)" { // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); // try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } + +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); + try 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 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); + } +} + +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 + 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); + } +} + +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.insertLines(0); +} + +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); + } +} + +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); + try 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); + } +} From e7bf9dc53c93e3a0dadafc44bde20ab56ae87da2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 19:31:23 -0800 Subject: [PATCH 062/428] vt-insert-lines bench --- src/bench/vt-insert-lines.sh | 12 +++++ src/bench/vt-insert-lines.zig | 88 +++++++++++++++++++++++++++++++++++ src/build_config.zig | 1 + src/main.zig | 1 + 4 files changed, 102 insertions(+) create mode 100755 src/bench/vt-insert-lines.sh create mode 100644 src/bench/vt-insert-lines.zig diff --git a/src/bench/vt-insert-lines.sh b/src/bench/vt-insert-lines.sh new file mode 100755 index 0000000000..5c19712cc5 --- /dev/null +++ b/src/bench/vt-insert-lines.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +hyperfine \ + --warmup 10 \ + -n new \ + "./zig-out/bin/bench-vt-insert-lines --mode=new${ARGS}" \ + -n old \ + "./zig-out/bin/bench-vt-insert-lines --mode=old${ARGS}" + diff --git a/src/bench/vt-insert-lines.zig b/src/bench/vt-insert-lines.zig new file mode 100644 index 0000000000..f7dc4064c4 --- /dev/null +++ b/src/bench/vt-insert-lines.zig @@ -0,0 +1,88 @@ +//! This benchmark tests the speed of the "insertLines" operation on a terminal. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli = @import("../cli.zig"); +const terminal = @import("../terminal/main.zig"); + +const Args = struct { + mode: Mode = .old, + + /// The number of times to loop. + count: usize = 15_000, + + /// Rows and cols in the terminal. + rows: usize = 100, + cols: usize = 300, + + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + pub fn deinit(self: *Args) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +const Mode = enum { + /// The default allocation strategy of the structure. + old, + + /// Use a memory pool to allocate pages from a backing buffer. + new, +}; + +pub const std_options: std.Options = .{ + .log_level = .debug, +}; + +pub fn main() !void { + // We want to use the c allocator because it is much faster than GPA. + const alloc = std.heap.c_allocator; + + // Parse our args + var args: Args = .{}; + defer args.deinit(); + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try cli.args.parse(Args, alloc, &args, &iter); + } + + // Handle the modes that do not depend on terminal state first. + switch (args.mode) { + .old => { + var t = try terminal.Terminal.init(alloc, args.cols, args.rows); + defer t.deinit(alloc); + try bench(&t, args); + }, + + .new => { + var t = try terminal.new.Terminal.init( + alloc, + @intCast(args.cols), + @intCast(args.rows), + ); + defer t.deinit(alloc); + try bench(&t, args); + }, + } +} + +noinline fn bench(t: anytype, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |_| { + for (0..args.rows) |i| { + _ = try t.insertLines(i); + } + } +} diff --git a/src/build_config.zig b/src/build_config.zig index 742a2b692b..d724cd77fa 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -144,4 +144,5 @@ pub const ExeEntrypoint = enum { bench_codepoint_width, bench_grapheme_break, bench_page_init, + bench_vt_insert_lines, }; diff --git a/src/main.zig b/src/main.zig index 3a5357471b..c66c8e2267 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,4 +11,5 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), .bench_page_init => @import("bench/page-init.zig"), + .bench_vt_insert_lines => @import("bench/vt-insert-lines.zig"), }; From e114a106f1e2952c5c4e790918ea6e70d9b1688f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 19:58:08 -0800 Subject: [PATCH 063/428] terminal/new: introduce content tags and bg color cells --- src/terminal/new/Screen.zig | 26 +++++--- src/terminal/new/Terminal.zig | 119 ++++++++++++++++++---------------- src/terminal/new/page.zig | 112 ++++++++++++++++++++++++-------- 3 files changed, 165 insertions(+), 92 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index e9fe40b14b..54a5e726c9 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -251,7 +251,7 @@ pub fn dumpString( break :cells cells[0..self.pages.cols]; }; - if (!pagepkg.Cell.hasText(cells)) { + if (!pagepkg.Cell.hasTextAny(cells)) { blank_rows += 1; continue; } @@ -274,7 +274,7 @@ pub fn dumpString( // 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.codepoint == 0) { + if (!cell.hasText()) { blank_cells += 1; continue; } @@ -283,13 +283,20 @@ pub fn dumpString( blank_cells = 0; } - try writer.print("{u}", .{cell.codepoint}); + switch (cell.content_tag) { + .codepoint => { + try writer.print("{u}", .{cell.content.codepoint}); + }, - if (cell.grapheme) { - const cps = row_offset.page.data.lookupGrapheme(cell).?; - for (cps) |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, } } } @@ -322,7 +329,8 @@ fn testWriteString(self: *Screen, text: []const u8) !void { assert(width == 1 or width == 2); switch (width) { 1 => { - self.cursor.page_cell.codepoint = c; + self.cursor.page_cell.content_tag = .codepoint; + self.cursor.page_cell.content = .{ .codepoint = c }; self.cursor.x += 1; if (self.cursor.x < self.pages.cols) { const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 31dc761882..334680806f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -245,7 +245,7 @@ pub fn print(self: *Terminal, c: u21) !void { // 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.codepoint == 0); + break :left @intFromBool(!self.screen.cursor.page_cell.hasText()); }; // If the previous cell is a wide spacer tail, then we actually @@ -263,12 +263,12 @@ pub fn print(self: *Terminal, c: u21) !void { // If our cell has no content, then this is a new cell and // necessarily a grapheme break. - if (prev.cell.codepoint == 0) break :grapheme; + if (!prev.cell.hasText()) break :grapheme; const grapheme_break = brk: { var state: unicode.GraphemeBreakState = .{}; - var cp1: u21 = prev.cell.codepoint; - if (prev.cell.grapheme) { + var cp1: u21 = prev.cell.content.codepoint; + if (prev.cell.hasGrapheme()) { const cps = self.screen.cursor.page_offset.page.data.lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); @@ -289,7 +289,7 @@ pub fn print(self: *Terminal, c: u21) !void { // VS15 makes it narrow. if (c == 0xFE0F or c == 0xFE0E) { // This only applies to emoji - const prev_props = unicode.getProperties(prev.cell.codepoint); + const prev_props = unicode.getProperties(prev.cell.content.codepoint); const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; if (!emoji) return; @@ -310,7 +310,7 @@ pub fn print(self: *Terminal, c: u21) !void { try self.printWrap(); } - self.printCell(prev.cell.codepoint, .wide); + self.printCell(prev.cell.content.codepoint, .wide); // Write our spacer self.screen.cursorRight(1); @@ -385,9 +385,15 @@ pub fn print(self: *Terminal, c: u21) !void { 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.codepoint); + const prev_props = unicode.getProperties(prev.content.codepoint); const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; if (!emoji) return; } @@ -513,7 +519,7 @@ fn printCell( } // If the prior value had graphemes, clear those - if (cell.grapheme) { + if (cell.hasGrapheme()) { self.screen.cursor.page_offset.page.data.clearGrapheme( self.screen.cursor.page_row, cell, @@ -522,8 +528,9 @@ fn printCell( // Write cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, .style_id = self.screen.cursor.style_id, - .codepoint = c, .wide = wide, }; @@ -1055,7 +1062,7 @@ test "Terminal: print wide char" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F600), cell.codepoint); + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -1077,7 +1084,7 @@ test "Terminal: print wide char in single-width terminal" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.codepoint); + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1096,13 +1103,13 @@ test "Terminal: print over wide char at 0,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.codepoint); + try testing.expectEqual(@as(u21, 'A'), 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, 0), cell.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1118,13 +1125,13 @@ test "Terminal: print over wide spacer tail" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.codepoint); + 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.codepoint); + try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -1156,8 +1163,8 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1165,16 +1172,16 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.codepoint); - try testing.expect(!cell.grapheme); + 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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F469), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1182,24 +1189,24 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.codepoint); - try testing.expect(!cell.grapheme); + 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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F467), cell.codepoint); - try testing.expect(!cell.grapheme); + 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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.codepoint); - try testing.expect(!cell.grapheme); + 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); } @@ -1224,8 +1231,8 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1249,14 +1256,14 @@ test "Terminal: print invalid VS16 non-grapheme" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.codepoint); - try testing.expect(!cell.grapheme); + 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.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); } } @@ -1284,8 +1291,8 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1293,8 +1300,8 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.codepoint); - try testing.expect(!cell.grapheme); + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } @@ -1318,8 +1325,8 @@ test "Terminal: VS15 to make narrow character" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x26C8), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1345,8 +1352,8 @@ test "Terminal: VS16 to make wide character with mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1374,8 +1381,8 @@ test "Terminal: VS16 repeated with mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1383,8 +1390,8 @@ test "Terminal: VS16 repeated with mode 2027" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.codepoint); - try testing.expect(cell.grapheme); + 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); @@ -1411,14 +1418,14 @@ test "Terminal: print invalid VS16 grapheme" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.codepoint); - try testing.expect(!cell.grapheme); + 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.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1444,16 +1451,16 @@ test "Terminal: print invalid VS16 with second char" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.codepoint); - try testing.expect(!cell.grapheme); + 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, 'y'), cell.codepoint); - try testing.expect(!cell.grapheme); + try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1479,8 +1486,8 @@ test "Terminal: overwrite grapheme should clear grapheme data" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.codepoint); - try testing.expect(!cell.grapheme); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1560,7 +1567,7 @@ test "Terminal: disabled wraparound with wide char and one space" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1587,7 +1594,7 @@ test "Terminal: disabled wraparound with wide char and no space" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -1616,7 +1623,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '❤'), cell.codepoint); + try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 95456f67b9..03e4e26d17 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -204,12 +204,14 @@ pub const Page = struct { /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) !void { + if (comptime std.debug.runtime_safety) assert(cell.hasText()); + const cell_offset = getOffset(Cell, self.memory, cell); var map = self.grapheme_map.map(self.memory); // If this cell has no graphemes, we can go faster by knowing we // need to allocate a new grapheme slice and update the map. - if (!cell.grapheme) { + if (cell.content_tag != .codepoint_grapheme) { const cps = try self.grapheme_alloc.alloc(u21, self.memory, 1); errdefer self.grapheme_alloc.free(self.memory, cps); cps[0] = cp; @@ -220,7 +222,7 @@ pub const Page = struct { }); errdefer map.remove(cell_offset); - cell.grapheme = true; + cell.content_tag = .codepoint_grapheme; row.grapheme = true; return; @@ -270,7 +272,7 @@ pub const Page = struct { /// Clear the graphemes for a given cell. pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { - assert(cell.grapheme); + if (comptime std.debug.runtime_safety) assert(cell.hasGrapheme()); // Get our entry in the map, which must exist const cell_offset = getOffset(Cell, self.memory, cell); @@ -286,9 +288,9 @@ pub const Page = struct { // Mark that we no longer have graphemes, also search the row // to make sure its state is correct. - cell.grapheme = false; + cell.content_tag = .codepoint; const cells = row.cells.ptr(self.memory)[0..self.size.cols]; - for (cells) |c| if (c.grapheme) return; + for (cells) |c| if (c.hasGrapheme()) return; row.grapheme = false; } @@ -444,26 +446,55 @@ pub const Row = packed struct(u64) { /// The zero value of this struct must be a valid cell representing empty, /// since we zero initialize the backing memory for a page. pub const Cell = packed struct(u64) { - /// The codepoint that this cell contains. If `grapheme` is false, - /// then this is the only codepoint in the cell. If `grapheme` is - /// true, then this is the first codepoint in the grapheme cluster. - codepoint: u21 = 0, + /// The content tag dictates the active tag in content and possibly + /// some other behaviors. + content_tag: ContentTag = .codepoint, + + /// The content of the cell. This is a union based on content_tag. + content: packed union { + /// The codepoint that this cell contains. If `grapheme` is false, + /// then this is the only codepoint in the cell. If `grapheme` is + /// true, then this is the first codepoint in the grapheme cluster. + codepoint: u21, + + /// The content is an empty cell with a background color. + color_palette: u8, + color_rgb: RGB, + } = .{ .codepoint = 0 }, /// The style ID to use for this cell within the style map. Zero /// is always the default style so no lookup is required. style_id: style.Id = 0, - /// This is true if there are additional codepoints in the grapheme - /// map for this cell to build a multi-codepoint grapheme. - grapheme: bool = false, - /// The wide property of this cell, for wide characters. Characters in /// a terminal grid can only be 1 or 2 cells wide. A wide character /// is always next to a spacer. This is used to determine both the width /// and spacer properties of a cell. wide: Wide = .narrow, - _padding: u24 = 0, + _padding: u20 = 0, + + pub const ContentTag = enum(u2) { + /// A single codepoint, could be zero to be empty cell. + codepoint = 0, + + /// A codepoint that is part of a multi-codepoint grapheme cluster. + /// The codepoint tag is active in content, but also expect more + /// codepoints in the grapheme data. + codepoint_grapheme = 1, + + /// The cell has no text but only a background color. This is an + /// optimization so that cells with only backgrounds don't take up + /// style map space and also don't require a style map lookup. + bg_color_palette = 2, + bg_color_rgb = 3, + }; + + pub const RGB = packed struct { + r: u8, + g: u8, + b: u8, + }; pub const Wide = enum(u2) { /// Not a wide character, cell width 1. @@ -480,10 +511,34 @@ pub const Cell = packed struct(u64) { spacer_head = 3, }; + /// Helper to make a cell that just has a codepoint. + pub fn init(codepoint: u21) Cell { + return .{ + .content_tag = .codepoint, + .content = .{ .codepoint = codepoint }, + }; + } + + pub fn hasText(self: Cell) bool { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint != 0, + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + + pub fn hasGrapheme(self: Cell) bool { + return self.content_tag == .codepoint_grapheme; + } + /// Returns true if the set of cells has text in it. - pub fn hasText(cells: []const Cell) bool { + pub fn hasTextAny(cells: []const Cell) bool { for (cells) |cell| { - if (cell.codepoint != 0) return true; + if (cell.hasText()) return true; } return false; @@ -569,13 +624,16 @@ test "Page read and write cells" { for (0..page.capacity.rows) |y| { const rac = page.getRowAndCell(1, y); - rac.cell.codepoint = @intCast(y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; } // Read it again for (0..page.capacity.rows) |y| { const rac = page.getRowAndCell(1, y); - try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.codepoint); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); } } @@ -588,24 +646,24 @@ test "Page appendGrapheme small" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.codepoint = 0x09; + rac.cell.* = Cell.init(0x09); // One try page.appendGrapheme(rac.row, rac.cell, 0x0A); try testing.expect(rac.row.grapheme); - try testing.expect(rac.cell.grapheme); + try testing.expect(rac.cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{0x0A}, page.lookupGrapheme(rac.cell).?); // Two try page.appendGrapheme(rac.row, rac.cell, 0x0B); try testing.expect(rac.row.grapheme); - try testing.expect(rac.cell.grapheme); + try testing.expect(rac.cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); // Clear it page.clearGrapheme(rac.row, rac.cell); try testing.expect(!rac.row.grapheme); - try testing.expect(!rac.cell.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); } test "Page appendGrapheme larger than chunk" { @@ -617,7 +675,7 @@ test "Page appendGrapheme larger than chunk" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.codepoint = 0x09; + rac.cell.* = Cell.init(0x09); const count = grapheme_chunk_len * 10; for (0..count) |i| { @@ -640,16 +698,16 @@ test "Page clearGrapheme not all cells" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.codepoint = 0x09; + rac.cell.* = Cell.init(0x09); try page.appendGrapheme(rac.row, rac.cell, 0x0A); const rac2 = page.getRowAndCell(1, 0); - rac2.cell.codepoint = 0x09; + rac2.cell.* = Cell.init(0x09); try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it page.clearGrapheme(rac.row, rac.cell); try testing.expect(rac.row.grapheme); - try testing.expect(!rac.cell.grapheme); - try testing.expect(rac2.cell.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + try testing.expect(rac2.cell.hasGrapheme()); } From e5ccbadf45799528d405b02d90f8e16001ba6a40 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 20:10:28 -0800 Subject: [PATCH 064/428] terminal/new: delete graphemes on insertLines --- src/terminal/new/Terminal.zig | 55 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 334680806f..70e3417808 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -941,21 +941,28 @@ pub fn insertLines(self: *Terminal, count: usize) !void { // 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_cells = dst.cells; - dst.cells = src.cells; - src.cells = dst_cells; - - // TODO: grapheme data for dst_cells should be deleted - // TODO: grapheme data for src.cells needs to be moved + const dst_row = dst.*; + dst.* = src.*; + src.* = dst_row; } for (0..adjusted_count) |i| { const row: *Row = @ptrCast(top + i); // Clear the src row. + var page = self.screen.cursor.page_offset.page.data; + const cells = page.getCells(row); + + // 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); + } + assert(!row.grapheme); + } + // TODO: cells should keep bg style of pen - // TODO: grapheme needs to be deleted - const cells = self.screen.cursor.page_offset.page.data.getCells(row); @memset(cells, .{}); } @@ -2185,3 +2192,35 @@ test "Terminal: insertLines resets wrap" { 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); + try t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); + } +} From 0f63cd6f017442b693609bde1517979aed111dff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 20:34:39 -0800 Subject: [PATCH 065/428] terminal/new: scrollDown, top/bot margin tests, fix insertLines bug --- src/bench/vt-insert-lines.zig | 22 +++- src/terminal/Terminal.zig | 7 ++ src/terminal/new/Terminal.zig | 226 ++++++++++++++++++++++++++++++---- 3 files changed, 228 insertions(+), 27 deletions(-) diff --git a/src/bench/vt-insert-lines.zig b/src/bench/vt-insert-lines.zig index f7dc4064c4..415e648956 100644 --- a/src/bench/vt-insert-lines.zig +++ b/src/bench/vt-insert-lines.zig @@ -56,7 +56,7 @@ pub fn main() !void { .old => { var t = try terminal.Terminal.init(alloc, args.cols, args.rows); defer t.deinit(alloc); - try bench(&t, args); + try benchOld(&t, args); }, .new => { @@ -66,12 +66,12 @@ pub fn main() !void { @intCast(args.rows), ); defer t.deinit(alloc); - try bench(&t, args); + try benchNew(&t, args); }, } } -noinline fn bench(t: anytype, args: Args) !void { +noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { // We fill the terminal with letters. for (0..args.rows) |row| { for (0..args.cols) |col| { @@ -86,3 +86,19 @@ noinline fn bench(t: anytype, args: Args) !void { } } } + +noinline fn benchNew(t: *terminal.new.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |_| { + for (0..args.rows) |i| { + _ = t.insertLines(i); + } + } +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0bbdc21a1f..7db282d0cb 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3327,6 +3327,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); @@ -3349,6 +3350,7 @@ test "Terminal: setTopAndBottomMargin simple" { } } +// X test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3371,6 +3373,7 @@ test "Terminal: setTopAndBottomMargin top only" { } } +// X test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3393,6 +3396,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); @@ -6945,6 +6949,7 @@ test "Terminal: cursorRight right of right margin" { } } +// X test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6969,6 +6974,7 @@ test "Terminal: scrollDown simple" { } } +// X test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7046,6 +7052,7 @@ test "Terminal: scrollDown outside of left/right scroll region" { } } +// X test "Terminal: scrollDown preserves pending wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 70e3417808..e733f6a91e 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -881,6 +881,22 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) 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); +} + /// 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 @@ -899,7 +915,7 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) /// 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 { +pub fn insertLines(self: *Terminal, count: usize) void { // Rare, but happens if (count == 0) return; @@ -921,29 +937,31 @@ pub fn insertLines(self: *Terminal, count: usize) !void { // region. So we take whichever is smaller. const adjusted_count = @min(count, 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 - adjusted_count; - // 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 + scroll_amount; - // TODO: detect active area split across multiple pages + // 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 - // 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); + // 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); - // 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; + // 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; + } } for (0..adjusted_count) |i| { @@ -2054,6 +2072,94 @@ test "Terminal: setCursorPos (original test)" { // try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } +test "Terminal: setTopAndBottomMargin 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.setTopAndBottomMargin(0, 0); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top only" { + 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, 0); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top and bottom" { + 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(1, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top equal to bottom" { + 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, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -2067,7 +2173,7 @@ test "Terminal: insertLines simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -2090,7 +2196,7 @@ test "Terminal: insertLines outside of scroll region" { try t.printString("GHI"); t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -2123,7 +2229,7 @@ test "Terminal: insertLines (legacy test)" { t.setCursorPos(2, 1); // Insert two lines - try t.insertLines(2); + t.insertLines(2); { const str = try t.plainString(testing.allocator); @@ -2139,7 +2245,7 @@ test "Terminal: insertLines zero" { // This should do nothing t.setCursorPos(1, 1); - try t.insertLines(0); + t.insertLines(0); } test "Terminal: insertLines more than remaining" { @@ -2166,7 +2272,7 @@ test "Terminal: insertLines more than remaining" { t.setCursorPos(2, 1); // Insert a bunch of lines - try t.insertLines(20); + t.insertLines(20); { const str = try t.plainString(testing.allocator); @@ -2182,7 +2288,7 @@ test "Terminal: insertLines resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - try t.insertLines(1); + t.insertLines(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('B'); @@ -2216,7 +2322,7 @@ test "Terminal: insertLines multi-codepoint graphemes" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -2224,3 +2330,75 @@ test "Terminal: insertLines multi-codepoint graphemes" { try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", 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 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); + } +} From 893770d98d578d42909eba77e43ce1c0625b39a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 20:52:43 -0800 Subject: [PATCH 066/428] terminal/new: eraseChars --- src/terminal/Terminal.zig | 4 ++ src/terminal/new/Screen.zig | 6 +- src/terminal/new/Terminal.zig | 122 +++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 7db282d0cb..9145dac334 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5160,6 +5160,7 @@ test "Terminal: eraseChars resets wrap" { } } +// X test "Terminal: eraseChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5177,6 +5178,7 @@ test "Terminal: eraseChars simple operation" { } } +// X test "Terminal: eraseChars minimum one" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5194,6 +5196,7 @@ test "Terminal: eraseChars minimum one" { } } +// X test "Terminal: eraseChars beyond screen edge" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5239,6 +5242,7 @@ test "Terminal: eraseChars preserves background sgr" { } } +// X test "Terminal: eraseChars wide character" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 54a5e726c9..24f68123e0 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -81,10 +81,10 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } -pub fn cursorCellRight(self: *Screen) *pagepkg.Cell { - assert(self.cursor.x + 1 < self.pages.cols); +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 + 1); + return @ptrCast(cell + n); } pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index e733f6a91e..f267a033ab 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -492,7 +492,7 @@ fn printCell( .wide => wide: { if (self.screen.cursor.x >= self.cols - 1) break :wide; - const spacer_cell = self.screen.cursorCellRight(); + 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(); @@ -997,6 +997,58 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.screen.cursor.pending_wrap = false; } +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = @max(count_req, 1); + + // 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 + // TODO: clear with current bg color + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + @memset(cells[0..end], .{}); + + // 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; + + // TODO: protected mode, see below for old logic + // + // 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; + // } +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -2402,3 +2454,71 @@ test "Terminal: scrollDown preserves pending wrap" { 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); + } +} From d86a47266e9e6a2e9320bf1daeec5983e795c58d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 20:55:47 -0800 Subject: [PATCH 067/428] terminal/new: one left/right margin test --- src/terminal/Terminal.zig | 1 + src/terminal/new/Terminal.zig | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9145dac334..4d81a8006b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3419,6 +3419,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { } } +// X test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index f267a033ab..e8a391c6a6 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -2212,6 +2212,29 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { } } +test "Terminal: setLeftAndRightMargin 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.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(0, 0); + t.eraseChars(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" BC\nDEF\nGHI", str); + } +} + test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From 0cbed73ff0a78782d40b6e35c6614cf0e47df259 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 21:10:44 -0800 Subject: [PATCH 068/428] terminal/new: cursorUp and reverseIndex --- src/terminal/Terminal.zig | 13 ++ src/terminal/new/Screen.zig | 8 +- src/terminal/new/Terminal.zig | 350 ++++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4d81a8006b..12b444f6b9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3854,6 +3854,7 @@ test "Terminal: insertLines outside of scroll region" { } } +// X test "Terminal: insertLines top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3949,6 +3950,7 @@ test "Terminal: insertLines zero" { try t.insertLines(0); } +// X test "Terminal: insertLines with scroll region" { const alloc = testing.allocator; var t = try init(alloc, 2, 6); @@ -4035,6 +4037,7 @@ test "Terminal: insertLines resets wrap" { } } +// X test "Terminal: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4062,6 +4065,7 @@ test "Terminal: reverseIndex" { } } +// X test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4095,6 +4099,7 @@ test "Terminal: reverseIndex from the top" { } } +// X test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 10); @@ -4128,6 +4133,7 @@ test "Terminal: reverseIndex top of scrolling region" { } } +// X test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4149,6 +4155,7 @@ test "Terminal: reverseIndex top of screen" { } } +// X test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4170,6 +4177,7 @@ test "Terminal: reverseIndex not top of screen" { } } +// X test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4191,6 +4199,7 @@ test "Terminal: reverseIndex top/bottom margins" { } } +// X test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6816,6 +6825,7 @@ test "Terminal: cursorDown resets wrap" { } } +// X test "Terminal: cursorUp basic" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6833,6 +6843,7 @@ test "Terminal: cursorUp basic" { } } +// X test "Terminal: cursorUp below top scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6851,6 +6862,7 @@ test "Terminal: cursorUp below top scroll margin" { } } +// X test "Terminal: cursorUp above top scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6870,6 +6882,7 @@ test "Terminal: cursorUp above top scroll margin" { } } +// X test "Terminal: cursorUp resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 24f68123e0..3d4af6e1de 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -123,15 +123,15 @@ pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { /// Move the cursor up. /// /// Precondition: The cursor is not at the top of the screen. -pub fn cursorUp(self: *Screen) void { - assert(self.cursor.y > 0); +pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.y >= n); - const page_offset = self.cursor.page_offset.backward(1).?; + const page_offset = self.cursor.page_offset.backward(n).?; const page_rac = page_offset.rowAndCell(self.cursor.x); self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - self.cursor.y -= 1; + self.cursor.y -= n; } /// Move the cursor down. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index e8a391c6a6..604941047d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -585,6 +585,24 @@ 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 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 @@ -791,6 +809,30 @@ pub fn index(self: *Terminal) !void { } } +/// 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 @@ -2280,6 +2322,32 @@ test "Terminal: insertLines outside of scroll region" { } } +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); @@ -2323,6 +2391,39 @@ test "Terminal: insertLines zero" { 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); @@ -2545,3 +2646,252 @@ test "Terminal: eraseChars wide character" { try testing.expectEqualStrings("X BC", 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: 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); + } +} From cc324b0cb735f1d1a0fb2944ef3060108e7c7ad8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 21:15:57 -0800 Subject: [PATCH 069/428] terminal/new: index tests --- src/terminal/Terminal.zig | 9 ++ src/terminal/new/Screen.zig | 2 +- src/terminal/new/Terminal.zig | 155 ++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 12b444f6b9..1dc52acde9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4265,6 +4265,7 @@ test "Terminal: reverseIndex outside left/right margins" { } } +// X test "Terminal: index" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4280,6 +4281,7 @@ test "Terminal: index" { } } +// X test "Terminal: index from the bottom" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4299,6 +4301,7 @@ test "Terminal: index from the bottom" { } } +// X test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4310,6 +4313,7 @@ test "Terminal: index outside of scrolling region" { 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); @@ -4328,6 +4332,7 @@ test "Terminal: index from the bottom outside of scroll region" { } } +// X test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4344,6 +4349,7 @@ test "Terminal: index no scroll region, top of screen" { } } +// X test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4386,6 +4392,7 @@ test "Terminal: index bottom of primary screen background sgr" { } } +// X test "Terminal: index inside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4423,6 +4430,7 @@ test "Terminal: index bottom of scroll region" { } } +// X test "Terminal: index bottom of primary screen with scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4444,6 +4452,7 @@ test "Terminal: index bottom of primary screen with scroll region" { } } +// X test "Terminal: index outside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 3d4af6e1de..9b3742314e 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -143,7 +143,7 @@ pub fn cursorDown(self: *Screen) void { // 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_offset = self.cursor.page_offset.forward(1).?; - const page_rac = page_offset.rowAndCell(0); + const page_rac = page_offset.rowAndCell(self.cursor.x); self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 604941047d..def324dbad 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -2824,6 +2824,161 @@ test "Terminal: reverseIndex outside top/bottom margins" { } } +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 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: cursorUp basic" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From 1a174dbb89bf352ab73bd517741b56efec852a03 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 21:38:13 -0800 Subject: [PATCH 070/428] terminal/new: deleteLines --- src/terminal/Terminal.zig | 4 + src/terminal/new/Terminal.zig | 270 ++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1dc52acde9..346f706473 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3541,6 +3541,7 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { } } +// X test "Terminal: deleteLines" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -3576,6 +3577,7 @@ test "Terminal: deleteLines" { } } +// X test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -3612,6 +3614,7 @@ test "Terminal: deleteLines with scroll region" { } } +// X test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -3694,6 +3697,7 @@ test "Terminal: deleteLines resets wrap" { } } +// X test "Terminal: deleteLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index def324dbad..629e3fd52c 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1039,6 +1039,99 @@ pub fn insertLines(self: *Terminal, count: usize) void { 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; + + if (self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1) + { + @panic("TODO: left/right margins"); + } + + // 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) { + 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); + + // 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; + } + } + + 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_offset.page.data; + const cells = page.getCells(row); + + // 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); + } + assert(!row.grapheme); + } + + // TODO: cells should keep bg style of pen + @memset(cells, .{}); + } + + // 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; +} + pub fn eraseChars(self: *Terminal, count_req: usize) void { const count = @max(count_req, 1); @@ -3050,3 +3143,180 @@ test "Terminal: cursorUp resets wrap" { try testing.expectEqualStrings("ABCDX", 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); + try t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +test "Terminal: deleteLines (legacy)" { + const alloc = testing.allocator; + var t = try init(alloc, 80, 80); + 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.cursorUp(2); + try t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nE\nD", str); + } +} + +test "Terminal: deleteLines with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 80, 80); + 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + try t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\nC\n\nD", str); + } +} + +// X +test "Terminal: deleteLines with scroll region, large count" { + const alloc = testing.allocator; + var t = try init(alloc, 80, 80); + 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + try t.deleteLines(5); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\n\n\nD", str); + } +} + +// X +test "Terminal: deleteLines with scroll region, cursor outside of region" { + const alloc = testing.allocator; + var t = try init(alloc, 80, 80); + 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB\nC\nD", str); + } +} + +test "Terminal: deleteLines 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); + try t.deleteLines(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", str); + } +} From 977f079fdd9c41852fec619ab23a38471a50097f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 21:42:19 -0800 Subject: [PATCH 071/428] terminal/new: scrollUp --- src/terminal/Terminal.zig | 7 ++ src/terminal/new/Terminal.zig | 146 +++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 346f706473..8276850e8d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3651,6 +3651,7 @@ test "Terminal: deleteLines with scroll region, large count" { } } +// X test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -3679,6 +3680,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); @@ -4414,6 +4416,7 @@ test "Terminal: index inside scroll region" { } } +// X test "Terminal: index bottom of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7105,6 +7108,7 @@ test "Terminal: scrollDown preserves pending wrap" { } } +// X test "Terminal: scrollUp simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7129,6 +7133,7 @@ test "Terminal: scrollUp simple" { } } +// X test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7178,6 +7183,7 @@ test "Terminal: scrollUp left/right scroll region" { } } +// X test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7199,6 +7205,7 @@ test "Terminal: scrollUp preserves pending wrap" { } } +// X test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 629e3fd52c..a05bf6f298 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -796,8 +796,7 @@ pub fn index(self: *Terminal) !void { { try self.screen.cursorDownScroll(); } else { - @panic("TODO: scroll up"); - //try self.scrollUp(1); + self.scrollUp(1); } return; @@ -939,6 +938,28 @@ pub fn scrollDown(self: *Terminal, count: usize) void { 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); +} + /// 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 @@ -1055,7 +1076,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { /// 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 { +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 @@ -2600,6 +2621,93 @@ test "Terminal: insertLines multi-codepoint graphemes" { } } +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 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: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3072,6 +3180,26 @@ test "Terminal: index outside left/right margin" { } } +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); @@ -3157,7 +3285,7 @@ test "Terminal: deleteLines simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - try t.deleteLines(1); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -3184,7 +3312,7 @@ test "Terminal: deleteLines (legacy)" { try t.print('D'); t.cursorUp(2); - try t.deleteLines(1); + t.deleteLines(1); try t.print('E'); t.carriageReturn(); @@ -3220,7 +3348,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(); @@ -3257,7 +3385,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(); @@ -3294,7 +3422,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); @@ -3310,7 +3438,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'); From 805abd4e299ca69bade1a798364655bfbfd718db Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 21:48:51 -0800 Subject: [PATCH 072/428] terminal/new: couple missing tests --- src/terminal/Terminal.zig | 2 ++ src/terminal/new/Terminal.zig | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8276850e8d..83a3f1e1c5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5141,6 +5141,7 @@ 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); @@ -5159,6 +5160,7 @@ test "Terminal: eraseChars resets pending wrap" { } } +// X test "Terminal: eraseChars resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index a05bf6f298..cba0e0afe9 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -2848,6 +2848,54 @@ test "Terminal: eraseChars wide character" { } } +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: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); From 14d25a4d825b76e7cf5aa957b9fc3cc3ec2c984f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 22:06:37 -0800 Subject: [PATCH 073/428] terminal/new: cursorLeft --- src/terminal/Terminal.zig | 13 ++ src/terminal/new/Screen.zig | 8 ++ src/terminal/new/Terminal.zig | 262 +++++++++++++++++++++++++++++++++- 3 files changed, 280 insertions(+), 3 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 83a3f1e1c5..53658d1ea4 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6519,6 +6519,7 @@ test "Terminal: eraseDisplay scroll complete" { } } +// X test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6537,6 +6538,7 @@ test "Terminal: cursorLeft no wrap" { } } +// X test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6555,6 +6557,7 @@ test "Terminal: cursorLeft unsets pending wrap state" { } } +// X test "Terminal: cursorLeft unsets pending wrap state with longer jump" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6573,6 +6576,7 @@ test "Terminal: cursorLeft unsets pending wrap state with longer jump" { } } +// X test "Terminal: cursorLeft reverse wrap with pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6594,6 +6598,7 @@ test "Terminal: cursorLeft reverse wrap with pending wrap state" { } } +// X test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6615,6 +6620,7 @@ test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { } } +// X test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6635,6 +6641,7 @@ test "Terminal: cursorLeft reverse wrap" { } } +// X test "Terminal: cursorLeft reverse wrap with no soft wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6657,6 +6664,7 @@ test "Terminal: cursorLeft reverse wrap with no soft wrap" { } } +// X test "Terminal: cursorLeft reverse wrap before left margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6675,6 +6683,7 @@ test "Terminal: cursorLeft reverse wrap before left margin" { } } +// X test "Terminal: cursorLeft extended reverse wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6697,6 +6706,7 @@ test "Terminal: cursorLeft extended reverse wrap" { } } +// X test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; var t = try init(alloc, 5, 3); @@ -6719,6 +6729,7 @@ test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { } } +// X test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; var t = try init(alloc, 5, 3); @@ -6742,6 +6753,7 @@ test "Terminal: cursorLeft extended reverse wrap is priority if both set" { } } +// X test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6758,6 +6770,7 @@ test "Terminal: cursorLeft extended reverse wrap above top scroll region" { 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); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 9b3742314e..8d1484b177 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -134,6 +134,14 @@ pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { self.cursor.y -= n; } +pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { + assert(self.cursor.y >= n); + + const page_offset = self.cursor.page_offset.backward(n).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + return page_rac.row; +} + /// Move the cursor down. /// /// Precondition: The cursor is not at the bottom of the screen. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index cba0e0afe9..f58815cda6 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -614,12 +614,12 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { break :wrap_mode .none; }; - var count: size.CellCountInt = @intCast(@max(count_req, 1)); + 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(count); + self.screen.cursorLeft(@min(count, self.screen.cursor.x)); self.screen.cursor.pending_wrap = false; return; } @@ -694,7 +694,8 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // If our previous line is not wrapped then we are done. if (wrap_mode != .reverse_extended) { - if (!self.screen.cursor.page_row.wrap) break; + const prev_row = self.screen.cursorRowUp(1); + if (!prev_row.wrap) break; } self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); @@ -3320,6 +3321,261 @@ test "Terminal: cursorUp resets wrap" { } } +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: deleteLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From a41239fddc9fa24061994e3c777de031b931008e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 22:09:28 -0800 Subject: [PATCH 074/428] terminal/new: cursorDown --- src/terminal/Terminal.zig | 4 ++ src/terminal/new/Screen.zig | 8 ++-- src/terminal/new/Terminal.zig | 89 ++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 53658d1ea4..a566eeecfc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6787,6 +6787,7 @@ test "Terminal: cursorLeft reverse wrap on first row" { 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); @@ -6803,6 +6804,7 @@ test "Terminal: cursorDown basic" { } } +// X test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6820,6 +6822,7 @@ test "Terminal: cursorDown above bottom scroll margin" { } } +// X test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6838,6 +6841,7 @@ test "Terminal: cursorDown below bottom scroll margin" { } } +// X test "Terminal: cursorDown resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 8d1484b177..c80db969af 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -145,19 +145,19 @@ pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { /// Move the cursor down. /// /// Precondition: The cursor is not at the bottom of the screen. -pub fn cursorDown(self: *Screen) void { - assert(self.cursor.y + 1 < self.pages.rows); +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_offset = self.cursor.page_offset.forward(1).?; + const page_offset = self.cursor.page_offset.forward(n).?; const page_rac = page_offset.rowAndCell(self.cursor.x); self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; // Y of course increases - self.cursor.y += 1; + self.cursor.y += n; } /// Move the cursor to some absolute horizontal position. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index f58815cda6..f00655e1b9 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -603,6 +603,22 @@ pub fn cursorUp(self: *Terminal, count_req: usize) void { 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 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 @@ -775,7 +791,7 @@ pub fn index(self: *Terminal) !void { // 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(); + self.screen.cursorDown(1); } return; @@ -805,7 +821,7 @@ pub fn index(self: *Terminal) !void { // Increase cursor by 1, maximum to bottom of scroll region if (self.screen.cursor.y < self.scrolling_region.bottom) { - self.screen.cursorDown(); + self.screen.cursorDown(1); } } @@ -3576,6 +3592,75 @@ test "Terminal: cursorLeft reverse wrap on first row" { 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: deleteLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From d30f50d5f0439dce8b68a26f39071e85facd0ae0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Feb 2024 22:17:06 -0800 Subject: [PATCH 075/428] terminal/new: cursorRight --- src/terminal/Terminal.zig | 4 ++ src/terminal/new/Terminal.zig | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a566eeecfc..2e5d946083 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6936,6 +6936,7 @@ test "Terminal: cursorUp resets wrap" { } } +// X test "Terminal: cursorRight resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6954,6 +6955,7 @@ test "Terminal: cursorRight resets wrap" { } } +// X test "Terminal: cursorRight to the edge of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6969,6 +6971,7 @@ test "Terminal: cursorRight to the edge of screen" { } } +// X test "Terminal: cursorRight left of right margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6985,6 +6988,7 @@ test "Terminal: cursorRight left of right margin" { } } +// X test "Terminal: cursorRight right of right margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index f00655e1b9..ae127e544d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -619,6 +619,23 @@ pub fn cursorDown(self: *Terminal, count_req: usize) void { 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 @@ -3661,6 +3678,72 @@ test "Terminal: cursorDown resets wrap" { } } +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); From c0ef9edbccf51239fadd3d8c41790327fb859447 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 09:47:32 -0800 Subject: [PATCH 076/428] terminal/new: start laying some groundwork for styles --- src/terminal/new/Screen.zig | 237 +++++++++++++++++++++++++++++++++- src/terminal/new/Terminal.zig | 36 ++++++ src/terminal/new/style.zig | 11 ++ 3 files changed, 283 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index c80db969af..98210aed49 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -3,6 +3,7 @@ const Screen = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const sgr = @import("../sgr.zig"); const unicode = @import("../../unicode/main.zig"); const PageList = @import("PageList.zig"); const pagepkg = @import("page.zig"); @@ -31,7 +32,12 @@ const Cursor = struct { /// next character print will force a soft-wrap. pending_wrap: bool = false, - /// The currently active style. The style is page-specific so when + /// 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, @@ -208,6 +214,7 @@ pub fn cursorDownScroll(self: *Screen) !void { } // No space, we need to allocate a new page and move the cursor to it. + // TODO: copy style over const new_page = try self.pages.grow(); assert(new_page.data.size.rows == 0); @@ -241,6 +248,171 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { } } +/// 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, + } + + var page = self.cursor.page_offset.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; +} + /// 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. @@ -360,9 +532,72 @@ test "Screen read and write" { 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 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_offset.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_offset.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_offset.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)); +} diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index ae127e544d..dd30c2c014 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1239,6 +1239,11 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // } } +/// Set a style attribute. +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + try self.screen.setAttribute(attr); +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -3920,3 +3925,34 @@ test "Terminal: deleteLines resets wrap" { try testing.expectEqualStrings("B", str); } } + +test "Terminal: default style is empty" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + 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.expectEqual(@as(style.Id, 0), cell.style_id); + } +} + +test "Terminal: bold style" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + 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); + } +} diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 3de5816761..9d867940d8 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -45,6 +45,12 @@ pub const Style = struct { rgb: color.RGB, }; + /// True if the style is the default style. + pub fn default(self: Style) bool { + const def: []const u8 = comptime std.mem.asBytes(&Style{}); + return std.mem.eql(u8, std.mem.asBytes(&self), def); + } + test { // The size of the struct so we can be aware of changes. const testing = std.testing; @@ -203,6 +209,11 @@ pub const Set = struct { id_map.removeByPtr(id_entry.key_ptr); style_map.removeByPtr(style_ptr); } + + /// Return the number of styles currently in the set. + pub fn count(self: *const Set, base: anytype) usize { + return self.id_map.map(base).count(); + } }; /// Metadata about a style. This is used to track the reference count From 37b8c02175becbeec1b6e90182eb5b715510b33f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 10:05:08 -0800 Subject: [PATCH 077/428] terminal/new: print handles previous styles --- src/terminal/new/Terminal.zig | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index dd30c2c014..ee4ed81489 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -473,10 +473,11 @@ fn printCell( unmapped_c: u21, wide: Cell.Wide, ) void { + // TODO: spacers should use a bgcolor only cell + // TODO: charsets const c: u21 = unmapped_c; - // TODO: prev cell overwriting style, dec refs, etc. const cell = self.screen.cursor.page_cell; // If the wide property of this cell is the same, then we don't @@ -526,6 +527,9 @@ fn printCell( ); } + // 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, @@ -534,9 +538,28 @@ fn printCell( .wide = wide, }; - // If we have non-default style then we need to update the ref count. - if (self.screen.cursor.style_ref) |ref| { - ref.* += 1; + // Handle the style ref count handling + style_ref: { + if (prev_style_id != style.default_id) { + // 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_offset.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; } } @@ -3954,5 +3977,51 @@ test "Terminal: bold style" { 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.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_offset.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +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); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + 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); } + + // verify we have no styles in our style map + const page = t.screen.cursor.page_offset.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } From 09f8c178002f96b6ba5bb3816479f6af4ea1d7f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 13:10:28 -0800 Subject: [PATCH 078/428] terminal/new: erase according to bg sgr --- src/terminal/Terminal.zig | 2 + src/terminal/new/PageList.zig | 14 ++- src/terminal/new/Screen.zig | 41 +++++---- src/terminal/new/Terminal.zig | 164 ++++++++++++++++++++++++++++++++-- src/terminal/new/style.zig | 19 ++++ 5 files changed, 216 insertions(+), 24 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2e5d946083..5f050e780d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4373,6 +4373,7 @@ test "Terminal: index bottom of primary screen" { } } +// X test "Terminal: index bottom of primary screen background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5241,6 +5242,7 @@ test "Terminal: eraseChars beyond screen edge" { } } +// X test "Terminal: eraseChars preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index f879df345b..7a1e463301 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -8,6 +8,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const point = @import("point.zig"); const pagepkg = @import("page.zig"); +const stylepkg = @import("style.zig"); const size = @import("size.zig"); const OffsetBuf = size.OffsetBuf; const Page = pagepkg.Page; @@ -488,13 +489,24 @@ const Cell = struct { row_idx: usize, col_idx: usize, + /// Get the cell style. + /// + /// Not meant for non-test usage since this is inefficient. + pub fn style(self: Cell) stylepkg.Style { + if (self.cell.style_id == stylepkg.default_id) return .{}; + return self.page.data.styles.lookupId( + self.page.data.memory, + self.cell.style_id, + ).?.*; + } + /// Gets the screen point for the given cell. /// /// This is REALLY expensive/slow so it isn't pub. This was built /// for debugging and tests. If you have a need for this outside of /// this file then consider a different approach and ask yourself very /// carefully if you really need this. - fn screenPoint(self: Cell) point.Point { + pub fn screenPoint(self: Cell) point.Point { var x: usize = self.col_idx; var y: usize = self.row_idx; var page = self.page; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 98210aed49..c0957f59db 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -199,10 +199,10 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) pub fn cursorDownScroll(self: *Screen) !void { assert(self.cursor.y == self.pages.rows - 1); - // If we have cap space in our current cursor page then we can take - // a fast path: update the size, recalculate the row/cell cursor pointers. const cursor_page = self.cursor.page_offset.page; if (cursor_page.data.capacity.rows > cursor_page.data.size.rows) { + // If we have cap space in our current cursor page then we can take + // a fast path: update the size, recalculate the row/cell cursor pointers. cursor_page.data.size.rows += 1; const page_offset = self.cursor.page_offset.forward(1).?; @@ -210,23 +210,30 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_offset = page_offset; self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - return; + } else { + // No space, we need to allocate a new page and move the cursor to it. + const new_page = try self.pages.grow(); + assert(new_page.data.size.rows == 0); + new_page.data.size.rows = 1; + const page_offset: PageList.RowOffset = .{ + .page = new_page, + .row_offset = 0, + }; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; } - // No space, we need to allocate a new page and move the cursor to it. - // TODO: copy style over - - const new_page = try self.pages.grow(); - assert(new_page.data.size.rows == 0); - new_page.data.size.rows = 1; - const page_offset: PageList.RowOffset = .{ - .page = new_page, - .row_offset = 0, - }; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + // 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); + } + } } /// Options for scrolling the viewport of the terminal grid. The reason diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index ee4ed81489..2fbe30c71a 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1084,6 +1084,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { } } + // Inserted lines should keep our bg color + const blank_cell = self.blankCell(); for (0..adjusted_count) |i| { const row: *Row = @ptrCast(top + i); @@ -1100,8 +1102,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { assert(!row.grapheme); } - // TODO: cells should keep bg style of pen - @memset(cells, .{}); + @memset(cells, blank_cell); } // Move the cursor to the left margin. But importantly this also @@ -1177,6 +1178,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { } const bottom: [*]Row = top + (rem - 1); + const blank_cell = self.blankCell(); while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { const row: *Row = @ptrCast(y); @@ -1193,8 +1195,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { assert(!row.grapheme); } - // TODO: cells should keep bg style of pen - @memset(cells, .{}); + @memset(cells, blank_cell); } // Move the cursor to the left margin. But importantly this also @@ -1230,9 +1231,8 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { }; // Clear the cells - // TODO: clear with current bg color const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - @memset(cells[0..end], .{}); + @memset(cells[0..end], self.blankCell()); // This resets the soft-wrap of this line self.screen.cursor.page_row.wrap = false; @@ -1275,6 +1275,13 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); } +/// Returns the blank cell to use when doing terminal operations that +/// require preserving the bg color. +fn blankCell(self: *const Terminal) Cell { + if (self.screen.cursor.style_id == style.default_id) return .{}; + return self.screen.cursor.style.bgCell() orelse .{}; +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, 40, 40); @@ -2475,6 +2482,44 @@ test "Terminal: insertLines simple" { } } +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 outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -2958,6 +3003,45 @@ test "Terminal: eraseChars resets wrap" { } } +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: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -3231,6 +3315,36 @@ test "Terminal: index bottom of primary screen" { } } +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); @@ -3794,6 +3908,44 @@ test "Terminal: deleteLines simple" { } } +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); diff --git a/src/terminal/new/style.zig b/src/terminal/new/style.zig index 9d867940d8..56c2e936ab 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal/new/style.zig @@ -51,6 +51,25 @@ pub const Style = struct { return std.mem.eql(u8, std.mem.asBytes(&self), def); } + /// Returns a bg-color only cell from this style, if it exists. + pub fn bgCell(self: Style) ?page.Cell { + return switch (self.bg_color) { + .none => null, + .palette => |idx| .{ + .content_tag = .bg_color_palette, + .content = .{ .color_palette = idx }, + }, + .rgb => |rgb| .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }, + }, + }; + } + test { // The size of the struct so we can be aware of changes. const testing = std.testing; From 1280301c08dba071fce3f71b19400cf692e42929 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 13:26:05 -0800 Subject: [PATCH 079/428] terminal/new: left/right margins insertLines --- src/terminal/Terminal.zig | 4 + src/terminal/new/Terminal.zig | 135 +++++++++++++++++++++++++++++++--- src/terminal/new/page.zig | 27 +++++++ 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5f050e780d..db0f79cdfa 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3443,6 +3443,7 @@ test "Terminal: setLeftAndRightMargin simple" { } } +// X test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3469,6 +3470,7 @@ test "Terminal: setLeftAndRightMargin left only" { } } +// X test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3493,6 +3495,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); @@ -3517,6 +3520,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 2fbe30c71a..be7678c31f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1045,11 +1045,6 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x > self.scrolling_region.right) return; - // TODO - if (self.scrolling_region.left > 0 or self.scrolling_region.right < self.cols - 1) { - @panic("TODO: left and right margin mode"); - } - // Remaining rows from our cursor to the bottom of the scroll region. const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; @@ -1071,16 +1066,33 @@ pub fn insertLines(self: *Terminal, count: usize) void { // 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); - // 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; + 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_offset.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); } } @@ -1102,7 +1114,10 @@ pub fn insertLines(self: *Terminal, count: usize) void { assert(!row.grapheme); } - @memset(cells, blank_cell); + @memset( + cells[self.scrolling_region.left .. self.scrolling_region.right + 1], + blank_cell, + ); } // Move the cursor to the left margin. But importantly this also @@ -2460,6 +2475,104 @@ test "Terminal: setLeftAndRightMargin simple" { } } +test "Terminal: setLeftAndRightMargin left only" { + 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.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 0); + 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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + } +} + +test "Terminal: setLeftAndRightMargin left and right" { + 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.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C\nABF\nDEI\nGH", str); + } +} + +test "Terminal: setLeftAndRightMargin left equal right" { + 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.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 2); + t.setCursorPos(1, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: setLeftAndRightMargin mode 69 unset" { + 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.modes.set(.enable_left_and_right_margin, false); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 03e4e26d17..c560624d3a 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -202,6 +202,33 @@ pub const Page = struct { return .{ .row = row, .cell = cell }; } + /// Move a cell from one location to another. This will replace the + /// previous contents with a blank cell. Because this is a move, this + /// doesn't allocate and can't fail. + pub fn moveCells( + self: *Page, + src_row: *Row, + src_left: usize, + dst_row: *Row, + dst_left: usize, + len: usize, + ) void { + const src_cells = src_row.cells.ptr(self.memory)[src_left .. src_left + len]; + const dst_cells = dst_row.cells.ptr(self.memory)[dst_left .. dst_left + len]; + + // If src has no graphemes, this is very fast. + const src_grapheme = src_row.grapheme or grapheme: { + for (src_cells) |c| if (c.hasGrapheme()) break :grapheme true; + break :grapheme false; + }; + if (!src_grapheme) { + @memcpy(dst_cells, src_cells); + return; + } + + @panic("TODO: grapheme move"); + } + /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) !void { if (comptime std.debug.runtime_safety) assert(cell.hasText()); From 898679ef7458c9b35d93bc0535942f6b7a99395e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 13:46:41 -0800 Subject: [PATCH 080/428] terminal/new: insert and delete lines handle style dec --- src/terminal/new/Screen.zig | 2 +- src/terminal/new/Terminal.zig | 141 +++++++++++++++++++++++++++++++--- src/terminal/new/page.zig | 9 ++- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index c0957f59db..d712271222 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -394,7 +394,7 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { .unknown => return, } - var page = self.cursor.page_offset.page.data; + var page = &self.cursor.page_offset.page.data; // Remove our previous style if is unused. if (self.cursor.style_ref) |ref| { diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index be7678c31f..d885cd8418 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -541,6 +541,9 @@ fn printCell( // 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; @@ -548,7 +551,7 @@ fn printCell( // 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_offset.page.data; + var page = &self.screen.cursor.page_offset.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; @@ -559,7 +562,10 @@ fn printCell( } // If we have a ref-counted style, increase. - if (self.screen.cursor.style_ref) |ref| ref.* += 1; + if (self.screen.cursor.style_ref) |ref| { + ref.* += 1; + self.screen.cursor.page_row.styled = true; + } } } @@ -1085,7 +1091,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { } // Left/right scroll margins we have to copy cells, which is much slower... - var page = self.screen.cursor.page_offset.page.data; + var page = &self.screen.cursor.page_offset.page.data; page.moveCells( src, self.scrolling_region.left, @@ -1102,7 +1108,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { const row: *Row = @ptrCast(top + i); // Clear the src row. - var page = self.screen.cursor.page_offset.page.data; + var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); // If this row has graphemes, then we need go through a slow path @@ -1114,10 +1120,41 @@ pub fn insertLines(self: *Terminal, count: usize) void { assert(!row.grapheme); } - @memset( - cells[self.scrolling_region.left .. self.scrolling_region.right + 1], - blank_cell, - ); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + + // Delete any styles. Note the `row.styled` check is VERY IMPORTANT + // for performance. Without this, every insert line is 4x slower. With + // this check, only styled rows are 4x slower. + if (row.styled) { + for (cells_write) |*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.screen.cursor.style_id) { + self.screen.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_write.len == self.cols) row.styled = false; + } + + @memset(cells_write, blank_cell); } // Move the cursor to the left margin. But importantly this also @@ -1198,7 +1235,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const row: *Row = @ptrCast(y); // Clear the src row. - var page = self.screen.cursor.page_offset.page.data; + var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); // If this row has graphemes, then we need go through a slow path @@ -1210,7 +1247,41 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { assert(!row.grapheme); } - @memset(cells, blank_cell); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + + // Delete any styles. Note the `row.styled` check is VERY IMPORTANT + // for performance. Without this, every insert line is 4x slower. With + // this check, only styled rows are 4x slower. + if (row.styled) { + for (cells_write) |*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.screen.cursor.style_id) { + self.screen.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_write.len == self.cols) row.styled = false; + } + + @memset(cells_write, blank_cell); } // Move the cursor to the left margin. But importantly this also @@ -2633,6 +2704,40 @@ test "Terminal: insertLines colors with bg color" { } } +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_offset.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); @@ -4290,3 +4395,19 @@ test "Terminal: do not garbage collect old styles in use" { const page = t.screen.cursor.page_offset.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } + +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'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.styled); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index c560624d3a..5112a74a7c 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -465,7 +465,14 @@ pub const Row = packed struct(u64) { /// grapheme data. grapheme: bool = false, - _padding: u29 = 0, + /// True if any of the cells in this row have a ref-counted style. + /// This can have false positives but never a false negative. Meaning: + /// this will be set to true the first time a style is used, but it + /// will not be set to false if the style is no longer used, because + /// checking for that condition is too expensive. + styled: bool = false, + + _padding: u28 = 0, }; /// A cell represents a single terminal grid cell. From 3354933fe3606b33aa2c47a77b50a253fb1975f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 13:54:26 -0800 Subject: [PATCH 081/428] terminal/new: more robust cell blanking in row --- src/terminal/new/Terminal.zig | 170 ++++++++++++++++------------------ 1 file changed, 79 insertions(+), 91 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index d885cd8418..e1e1ec83bd 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -24,6 +24,7 @@ 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; @@ -1103,58 +1104,14 @@ pub fn insertLines(self: *Terminal, count: usize) void { } // Inserted lines should keep our bg color - const blank_cell = self.blankCell(); for (0..adjusted_count) |i| { const row: *Row = @ptrCast(top + i); // Clear the src row. var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); - - // 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); - } - assert(!row.grapheme); - } - const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - - // Delete any styles. Note the `row.styled` check is VERY IMPORTANT - // for performance. Without this, every insert line is 4x slower. With - // this check, only styled rows are 4x slower. - if (row.styled) { - for (cells_write) |*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.screen.cursor.style_id) { - self.screen.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_write.len == self.cols) row.styled = false; - } - - @memset(cells_write, blank_cell); + self.blankCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1230,58 +1187,14 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { } const bottom: [*]Row = top + (rem - 1); - const blank_cell = self.blankCell(); while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { const row: *Row = @ptrCast(y); // Clear the src row. var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); - - // 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); - } - assert(!row.grapheme); - } - const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - - // Delete any styles. Note the `row.styled` check is VERY IMPORTANT - // for performance. Without this, every insert line is 4x slower. With - // this check, only styled rows are 4x slower. - if (row.styled) { - for (cells_write) |*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.screen.cursor.style_id) { - self.screen.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_write.len == self.cols) row.styled = false; - } - - @memset(cells_write, blank_cell); + self.blankCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1318,7 +1231,11 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // Clear the cells const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - @memset(cells[0..end], self.blankCell()); + self.blankCells( + &self.screen.cursor.page_offset.page.data, + self.screen.cursor.page_row, + cells[0..end], + ); // This resets the soft-wrap of this line self.screen.cursor.page_row.wrap = false; @@ -1348,6 +1265,55 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // } } +/// Blank the given cells. The cells must be long to the given row and page. +/// This will handle refcounted styles properly as well as graphemes. +fn blankCells( + self: *const Terminal, + 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); + } + assert(!row.grapheme); + } + + 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.screen.cursor.style_id) { + self.screen.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.cols) row.styled = false; + } + + @memset(cells, self.blankCell()); +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screen.setAttribute(attr); @@ -3260,6 +3226,28 @@ test "Terminal: eraseChars preserves background sgr" { } } +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_offset.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: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); From d38e075a7c93d5e054d9b47545bdf4bc75e170e0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 14:07:28 -0800 Subject: [PATCH 082/428] terminal/new: lots more scroll region tests --- src/terminal/Terminal.zig | 7 ++ src/terminal/new/Terminal.zig | 209 ++++++++++++++++++++++++++++++++-- src/terminal/new/page.zig | 5 + 3 files changed, 210 insertions(+), 11 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index db0f79cdfa..fccfbf4aca 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3726,6 +3726,7 @@ test "Terminal: deleteLines simple" { } } +// X test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -3769,6 +3770,7 @@ test "Terminal: deleteLines left/right scroll region clears row wrap" { } } +// X test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -3793,6 +3795,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); @@ -3891,6 +3894,7 @@ test "Terminal: insertLines top/bottom scroll region" { } } +// X test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -4231,6 +4235,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { } } +// X test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4253,6 +4258,7 @@ test "Terminal: reverseIndex left/right margins" { } } +// X test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4486,6 +4492,7 @@ test "Terminal: index outside left/right margin" { } } +// X test "Terminal: index inside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index e1e1ec83bd..cab132b8a6 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -212,6 +212,14 @@ pub fn printString(self: *Terminal, str: []const u8) !void { } } +/// 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 }); @@ -1150,12 +1158,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x > self.scrolling_region.right) return; - if (self.scrolling_region.left > 0 or - self.scrolling_region.right < self.cols - 1) - { - @panic("TODO: left/right margins"); - } - // 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. @@ -1173,16 +1175,33 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { // "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); - // 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; + 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_offset.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); } } @@ -2912,6 +2931,30 @@ test "Terminal: insertLines multi-codepoint graphemes" { } } +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); @@ -3425,6 +3468,50 @@ test "Terminal: reverseIndex outside top/bottom margins" { } } +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); @@ -3610,6 +3697,34 @@ test "Terminal: index outside left/right margin" { } } +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); @@ -4307,6 +4422,78 @@ test "Terminal: deleteLines resets wrap" { } } +test "Terminal: deleteLines 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.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); + } +} + +test "Terminal: deleteLines left/right scroll region from top" { + 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, 2); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } +} + +test "Terminal: deleteLines left/right scroll region high count" { + 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.deleteLines(100); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); + } +} + test "Terminal: default style is empty" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 5112a74a7c..6a37669f78 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -470,6 +470,11 @@ pub const Row = packed struct(u64) { /// this will be set to true the first time a style is used, but it /// will not be set to false if the style is no longer used, because /// checking for that condition is too expensive. + /// + /// Why have this weird false positive flag at all? This makes VT operations + /// that erase cells (such as insert lines, delete lines, erase chars, + /// etc.) MUCH MUCH faster in the case that the row was never styled. + /// At the time of writing this, the speed difference is around 4x. styled: bool = false, _padding: u28 = 0, From dfd46a850b6b03361696aa1c8d1660441c9e8d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 14:17:16 -0800 Subject: [PATCH 083/428] terminal/new: decaln --- src/terminal/Terminal.zig | 3 + src/terminal/new/Screen.zig | 5 ++ src/terminal/new/Terminal.zig | 122 ++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index fccfbf4aca..e8dedc6e44 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4521,6 +4521,7 @@ test "Terminal: index inside left/right margin" { } } +// X test "Terminal: DECALN" { const alloc = testing.allocator; var t = try init(alloc, 2, 2); @@ -4543,6 +4544,7 @@ test "Terminal: DECALN" { } } +// X test "Terminal: decaln reset margins" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); @@ -4561,6 +4563,7 @@ test "Terminal: decaln reset margins" { } } +// X test "Terminal: decaln preserves color" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index d712271222..6fd0f29803 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -394,6 +394,11 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { .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_offset.page.data; // Remove our previous style if is unused. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index cab132b8a6..a71c08b538 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1333,6 +1333,59 @@ fn blankCells( @memset(cells, self.blankCell()); } +/// 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 { + // TODO: erase display to gc graphemes, styles + + // 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); + + // Fill with Es, does not move cursor. + // TODO: cursor across pages + var page = &self.screen.cursor.page_offset.page.data; + const rows: [*]Row = @ptrCast(self.screen.cursor.page_row); + for (0..self.rows) |y| { + const row: *Row = @ptrCast(rows + y); + const cells = page.getCells(row); + @memset(cells, .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'E' }, + .style_id = self.screen.cursor.style_id, + }); + + // If we have a ref-counted style, increase + if (self.screen.cursor.style_ref) |ref| { + ref.* += @intCast(cells.len); + row.styled = true; + } + } +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screen.setAttribute(attr); @@ -4586,3 +4639,72 @@ test "Terminal: print with style marks the row as styled" { try testing.expect(list_cell.row.styled); } } + +test "Terminal: DECALN" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 2); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + try t.decaln(); + + try testing.expectEqual(@as(usize, 0), 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("EE\nEE", str); + } +} + +test "Terminal: decaln reset margins" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + // Initial value + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + } +} + +test "Terminal: decaln preserves color" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + // Initial value + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + 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); + } +} From 23b0c1fad96b0cb95f97d2e9e3e7facac17b86d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 19:36:33 -0800 Subject: [PATCH 084/428] terminal/new: insertBlanks, insert mode --- src/terminal/Terminal.zig | 16 ++ src/terminal/new/Terminal.zig | 384 +++++++++++++++++++++++++++++++++- src/terminal/new/page.zig | 7 + 3 files changed, 405 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index e8dedc6e44..0d8ff5f74d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4589,6 +4589,7 @@ test "Terminal: decaln preserves color" { } } +// X test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -4612,6 +4613,7 @@ test "Terminal: insertBlanks" { } } +// X test "Terminal: insertBlanks pushes off end" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -4632,6 +4634,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. @@ -4652,6 +4655,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); @@ -4668,6 +4672,7 @@ 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); @@ -4691,6 +4696,7 @@ test "Terminal: insertBlanks preserves background sgr" { } } +// X test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); @@ -4708,6 +4714,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); @@ -4725,6 +4732,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); @@ -4745,6 +4753,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); @@ -4766,6 +4775,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); @@ -4785,6 +4795,7 @@ test "Terminal: insertBlanks left/right scroll region large count" { } } +// X test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, 10, 2); @@ -4802,6 +4813,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); @@ -4819,6 +4831,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); @@ -4835,6 +4848,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); @@ -4852,6 +4866,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); @@ -4868,6 +4883,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index a71c08b538..c9dc49bc6d 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -428,8 +428,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (self.modes.get(.insert) and self.screen.cursor.x + width < self.cols) { - @panic("TODO: insert mode"); - //self.insertBlanks(width); + self.insertBlanks(width); } switch (width) { @@ -1229,6 +1228,86 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { 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_offset.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.blankCells(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()) { + page.moveGraphemeWithinRow(src, dst); + } + } + } + + // Insert blanks. The blanks preserve the background color. + self.blankCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); +} + pub fn eraseChars(self: *Terminal, count_req: usize) void { const count = @max(count_req, 1); @@ -4708,3 +4787,304 @@ test "Terminal: decaln preserves color" { }, list_cell.cell.content.color_rgb); } } + +test "Terminal: insertBlanks" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } +} + +test "Terminal: insertBlanks pushes off end" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +test "Terminal: insertBlanks more than size" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(5); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: insertBlanks no scroll region, fits" { + 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); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } +} + +test "Terminal: insertBlanks 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.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); + } +} + +test "Terminal: insertBlanks shift off screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 10); + defer t.deinit(alloc); + + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: insertBlanks split multi-cell character" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 10); + defer t.deinit(alloc); + + for ("123") |c| try t.print(c); + try t.print('橋'); + t.setCursorPos(1, 1); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 123", str); + } +} + +test "Terminal: insertBlanks inside left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 3); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: insertBlanks outside left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 6, 10); + defer t.deinit(alloc); + + t.setCursorPos(1, 4); + for ("ABC") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.insertBlanks(2); + 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(" ABX", str); + } +} + +test "Terminal: insertBlanks left/right scroll region large count" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 1); + t.insertBlanks(140); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: insert mode with space" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 2); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXello", str); + } +} + +test "Terminal: insert mode doesn't wrap pushed characters" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXell", str); + } +} + +test "Terminal: insert mode does nothing at the end of the line" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello\nX", str); + } +} + +test "Terminal: insert mode with wide characters" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("h😀el", str); + } +} + +test "Terminal: insert mode with wide characters at end" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + for ("well") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("well\n😀", str); + } +} + +test "Terminal: insert mode pushing off wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 2); + defer t.deinit(alloc); + + for ("123") |c| try t.print(c); + try t.print('😀'); // 0x1F600 + t.modes.set(.insert, true); + t.setCursorPos(1, 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X123", str); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 6a37669f78..c799ee4c6f 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -321,6 +321,13 @@ pub const Page = struct { row.grapheme = false; } + /// Move graphemes to another cell in the same row. + pub fn moveGraphemeWithinRow(self: *Page, src: *Cell, dst: *Cell) void { + _ = self; + _ = src; + _ = dst; + } + pub const Layout = struct { total_size: usize, rows_start: usize, From 7a4d2817f8bb4fb7d3a42b175be6bba1f6e80b27 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 19:55:14 -0800 Subject: [PATCH 085/428] terminal/new: grapheme tests --- src/terminal/new/Terminal.zig | 70 ++++++++++++++++++++++++++++++++++- src/terminal/new/page.zig | 25 +++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index c9dc49bc6d..d6d4828220 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1299,6 +1299,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // 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); } } @@ -1377,7 +1378,6 @@ fn blankCells( for (cells) |*cell| { if (cell.hasGrapheme()) page.clearGrapheme(row, cell); } - assert(!row.grapheme); } if (row.styled) { @@ -4988,6 +4988,74 @@ 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_offset.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_offset.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); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index c799ee4c6f..adc4752645 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -321,11 +321,30 @@ pub const Page = struct { row.grapheme = false; } + /// Returns the number of graphemes in the page. This isn't the byte + /// size but the total number of unique cells that have grapheme data. + pub fn graphemeCount(self: *const Page) usize { + return self.grapheme_map.map(self.memory).count(); + } + /// Move graphemes to another cell in the same row. pub fn moveGraphemeWithinRow(self: *Page, src: *Cell, dst: *Cell) void { - _ = self; - _ = src; - _ = dst; + // Note: we don't assert src has graphemes here because one of + // the places we call this is from insertBlanks where the cells have + // already swapped cell data but not grapheme data. + + // Get our entry in the map, which must exist + const src_offset = getOffset(Cell, self.memory, src); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + + // Remove the entry so we know we have space + map.removeByPtr(entry.key_ptr); + + // Add the entry for the new cell + const dst_offset = getOffset(Cell, self.memory, dst); + map.putAssumeCapacity(dst_offset, value); } pub const Layout = struct { From 7fe7c56e2bd921b04ee08486336537c9845c255a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 21:29:42 -0800 Subject: [PATCH 086/428] terminal/new: deleteChars --- src/terminal/Terminal.zig | 13 ++ src/terminal/new/Terminal.zig | 305 ++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0d8ff5f74d..aa439d0d92 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4948,6 +4948,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { 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); @@ -4956,6 +4957,7 @@ test "Terminal: print wide char with 1-column width" { try t.print('😀'); // 0x1F600 } +// X test "Terminal: deleteChars" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4978,6 +4980,7 @@ test "Terminal: deleteChars" { } } +// X test "Terminal: deleteChars zero count" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4994,6 +4997,7 @@ test "Terminal: deleteChars zero count" { } } +// X test "Terminal: deleteChars more than half" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5010,6 +5014,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); @@ -5026,6 +5031,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); @@ -5042,6 +5048,7 @@ test "Terminal: deleteChars should shift left" { } } +// X test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5060,6 +5067,7 @@ test "Terminal: deleteChars resets wrap" { } } +// X test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5076,6 +5084,7 @@ test "Terminal: deleteChars simple operation" { } } +// X test "Terminal: deleteChars background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5101,6 +5110,7 @@ test "Terminal: deleteChars background sgr" { } } +// X test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -5120,6 +5130,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); @@ -5138,6 +5149,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); @@ -5154,6 +5166,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index d6d4828220..816bb66771 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1309,6 +1309,88 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { self.blankCells(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_offset.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.blankCells(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.blankCells(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.blankCells(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); @@ -1577,6 +1659,14 @@ test "Terminal: print wide char" { } } +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); @@ -5156,3 +5246,218 @@ test "Terminal: insert mode pushing off wide character" { try testing.expectEqualStrings("X123", str); } } + +test "Terminal: deleteChars" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(2); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ADE", str); + } +} + +test "Terminal: deleteChars zero count" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(0); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE", str); + } +} + +test "Terminal: deleteChars more than half" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(3); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AE", str); + } +} + +test "Terminal: deleteChars more than line width" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(10); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: deleteChars should shift left" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.deleteChars(1); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ACDE", str); + } +} + +test "Terminal: deleteChars 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.deleteChars(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: deleteChars simple operation" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.setCursorPos(1, 3); + t.deleteChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB23", str); + } +} + +test "Terminal: deleteChars preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + for ("ABC123") |c| try t.print(c); + t.setCursorPos(1, 3); + 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 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); + } +} + +test "Terminal: deleteChars outside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 6, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.deleteChars(2); + try testing.expect(t.screen.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123", str); + } +} + +test "Terminal: deleteChars inside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 6, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 4); + t.deleteChars(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC2 3", str); + } +} + +test "Terminal: deleteChars split wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 6, 10); + defer t.deinit(alloc); + + try t.printString("A橋123"); + t.setCursorPos(1, 3); + t.deleteChars(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 123", str); + } +} + +test "Terminal: deleteChars split wide character tail" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, t.cols - 1); + try t.print(0x6A4B); // 橋 + t.carriageReturn(); + t.deleteChars(t.cols - 1); + try t.print('0'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("0", str); + } +} From b4ed0e6cbeeb3de984c3ddb7d925e65f70c537b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 21:38:59 -0800 Subject: [PATCH 087/428] terminal/new: saved cursor --- src/terminal/Terminal.zig | 4 ++ src/terminal/new/Screen.zig | 16 ++++- src/terminal/new/Terminal.zig | 125 ++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index aa439d0d92..a9998a037e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5462,6 +5462,7 @@ test "Terminal: resize with wraparound on" { try testing.expectEqualStrings("01\n23", str); } +// X test "Terminal: saveCursor" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); @@ -5510,6 +5511,7 @@ test "Terminal: saveCursor with screen change" { try testing.expect(t.modes.get(.origin)); } +// X test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5530,6 +5532,7 @@ test "Terminal: saveCursor position" { } } +// X test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5550,6 +5553,7 @@ test "Terminal: saveCursor pending wrap state" { } } +// X test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 6fd0f29803..27947ba909 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -22,8 +22,11 @@ pages: PageList, /// The current cursor position cursor: Cursor, +/// The saved cursor +saved_cursor: ?SavedCursor = null, + /// The cursor position. -const Cursor = struct { +pub const Cursor = struct { // The x/y position within the viewport. x: size.CellCountInt, y: size.CellCountInt, @@ -50,6 +53,17 @@ const Cursor = struct { page_cell: *pagepkg.Cell, }; +/// Saved cursor state. +pub const SavedCursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + style: style.Style, + pending_wrap: bool, + origin: bool, + // TODO + //charset: CharsetState, +}; + /// Initialize a new screen. pub fn init( alloc: Allocator, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 816bb66771..c04f48483f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -773,6 +773,53 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { } } +/// 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, + .pending_wrap = self.screen.cursor.pending_wrap, + .origin = self.modes.get(.origin), + //TODO + //.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 = .{}, + .pending_wrap = false, + .origin = false, + // TODO + //.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.cursorAbsolute( + @min(saved.x, self.cols - 1), + @min(saved.y, self.rows - 1), + ); +} + /// Horizontal tab moves the cursor to the next tabstop, clearing /// the screen to the left the tabstop. pub fn horizontalTab(self: *Terminal) !void { @@ -5461,3 +5508,81 @@ test "Terminal: deleteChars split wide character tail" { try testing.expectEqualStrings("0", str); } } + +test "Terminal: saveCursor" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + //t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + t.saveCursor(); + //t.screen.charset.gr = .G0; + try t.setAttribute(.{ .unset = {} }); + t.modes.set(.origin, false); + 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)); +} + +test "Terminal: saveCursor position" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B AX", str); + } +} + +test "Terminal: saveCursor pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B A\nX", str); + } +} + +test "Terminal: saveCursor origin mode" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setTopAndBottomMargin(2, 4); + try t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} From fd0ab1a80b6eadd970ace19876d1f29c6839551e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 21:56:23 -0800 Subject: [PATCH 088/428] terminal/new: save cursor, protected modes --- src/terminal/Terminal.zig | 20 ++++ src/terminal/new/Screen.zig | 13 +++ src/terminal/new/Terminal.zig | 171 ++++++++++++++++++++++++++++------ src/terminal/new/page.zig | 5 +- 4 files changed, 178 insertions(+), 31 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a9998a037e..87bed33f7a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5334,6 +5334,7 @@ test "Terminal: eraseChars wide character" { } } +// X test "Terminal: eraseChars protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5351,6 +5352,7 @@ test "Terminal: eraseChars protected attributes respected with iso" { } } +// X test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5370,6 +5372,7 @@ test "Terminal: eraseChars protected attributes ignored with dec most recent" { } } +// X test "Terminal: eraseChars protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5592,6 +5595,23 @@ 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); + 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); +} + +// X test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 27947ba909..075c51743f 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -3,6 +3,7 @@ const Screen = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const ansi = @import("../ansi.zig"); const sgr = @import("../sgr.zig"); const unicode = @import("../../unicode/main.zig"); const PageList = @import("PageList.zig"); @@ -25,6 +26,13 @@ cursor: Cursor, /// The saved cursor saved_cursor: ?SavedCursor = null, +/// 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 cursor position. pub const Cursor = struct { // The x/y position within the viewport. @@ -35,6 +43,10 @@ pub const Cursor = struct { /// 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. @@ -58,6 +70,7 @@ pub const SavedCursor = struct { x: size.CellCountInt, y: size.CellCountInt, style: style.Style, + protected: bool, pending_wrap: bool, origin: bool, // TODO diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index c04f48483f..93b387ad13 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -544,6 +544,7 @@ fn printCell( .content = .{ .codepoint = c }, .style_id = self.screen.cursor.style_id, .wide = wide, + .protected = self.screen.cursor.protected, }; // Handle the style ref count handling @@ -783,6 +784,7 @@ pub fn saveCursor(self: *Terminal) void { .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), //TODO @@ -799,6 +801,7 @@ pub fn restoreCursor(self: *Terminal) !void { .x = 0, .y = 0, .style = .{}, + .protected = false, .pending_wrap = false, .origin = false, // TODO @@ -814,12 +817,36 @@ pub fn restoreCursor(self: *Terminal) !void { //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; + }, + } +} + /// Horizontal tab moves the cursor to the next tabstop, clearing /// the screen to the left the tabstop. pub fn horizontalTab(self: *Terminal) !void { @@ -1441,6 +1468,12 @@ pub fn deleteChars(self: *Terminal, count: usize) void { 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: { @@ -1459,38 +1492,32 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // Clear the cells const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - self.blankCells( - &self.screen.cursor.page_offset.page.data, - self.screen.cursor.page_row, - cells[0..end], - ); - // 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; + // 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.blankCells( + &self.screen.cursor.page_offset.page.data, + self.screen.cursor.page_row, + cells[0..end], + ); + return; + } - // TODO: protected mode, see below for old logic - // - // 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; - // } + // 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.blankCells( + &self.screen.cursor.page_offset.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); + } } /// Blank the given cells. The cells must be long to the given row and page. @@ -3560,6 +3587,59 @@ test "Terminal: eraseChars handles refcounted styles" { 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); @@ -5586,3 +5666,34 @@ test "Terminal: saveCursor origin mode" { try testing.expectEqualStrings("X", str); } } + +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); + 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); +} + +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); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.protected); + t.setProtectedMode(.iso); + try testing.expect(t.screen.cursor.protected); + t.setProtectedMode(.dec); + try testing.expect(t.screen.cursor.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.protected); +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index adc4752645..a3cb6305e9 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -537,7 +537,10 @@ pub const Cell = packed struct(u64) { /// and spacer properties of a cell. wide: Wide = .narrow, - _padding: u20 = 0, + /// Whether this was written with the protection flag set. + protected: bool = false, + + _padding: u19 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. From 0e259abbf5bd406067503dee4714bda0c4a3ad06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 21:57:44 -0800 Subject: [PATCH 089/428] terminal/new: clear out some TODOs --- src/terminal/new/Terminal.zig | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 93b387ad13..04a7607c44 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1611,6 +1611,7 @@ pub fn decaln(self: *Terminal) !void { .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 @@ -2696,24 +2697,23 @@ test "Terminal: setCursorPos (original test)" { try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); // Set the scroll region - // TODO - // t.setTopAndBottomMargin(10, t.rows); - // t.setCursorPos(0, 0); - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - // - // t.setCursorPos(1, 1); - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - // - // t.setCursorPos(100, 0); - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - // - // t.setTopAndBottomMargin(10, 11); - // t.setCursorPos(2, 0); - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); + t.setTopAndBottomMargin(10, t.rows); + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + + t.setCursorPos(1, 1); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + + t.setCursorPos(100, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + + t.setTopAndBottomMargin(10, 11); + t.setCursorPos(2, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } test "Terminal: setTopAndBottomMargin simple" { From 9ad76c6482124e73a0a77dc6e755b404cb3d1122 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 22:10:03 -0800 Subject: [PATCH 090/428] terminal/new: eraseLine --- src/terminal/Terminal.zig | 22 ++ src/terminal/new/Terminal.zig | 519 ++++++++++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 87bed33f7a..22f2590862 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5628,6 +5628,7 @@ test "Terminal: setProtectedMode" { 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); @@ -5644,6 +5645,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); @@ -5662,6 +5664,7 @@ test "Terminal: eraseLine resets pending wrap" { } } +// X test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5689,6 +5692,7 @@ test "Terminal: eraseLine resets wrap" { } } +// X test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5714,6 +5718,7 @@ test "Terminal: eraseLine right preserves background sgr" { } } +// X test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5732,6 +5737,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); @@ -5749,6 +5755,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); @@ -5768,6 +5775,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); @@ -5785,6 +5793,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); @@ -5804,6 +5813,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); @@ -5820,6 +5830,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); @@ -5838,6 +5849,7 @@ 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); @@ -5863,6 +5875,7 @@ test "Terminal: eraseLine left preserves background sgr" { } } +// X test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5881,6 +5894,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); @@ -5898,6 +5912,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); @@ -5917,6 +5932,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); @@ -5934,6 +5950,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); @@ -5953,6 +5970,7 @@ 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); @@ -5978,6 +5996,7 @@ test "Terminal: eraseLine complete preserves background sgr" { } } +// X test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5995,6 +6014,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); @@ -6014,6 +6034,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); @@ -6031,6 +6052,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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 04a7607c44..03d0e0a90f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1520,6 +1520,88 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { } } +/// 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.blankCells( + &self.screen.cursor.page_offset.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.blankCells( + &self.screen.cursor.page_offset.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); + } +} + /// Blank the given cells. The cells must be long to the given row and page. /// This will handle refcounted styles properly as well as graphemes. fn blankCells( @@ -5697,3 +5779,440 @@ test "Terminal: setProtectedMode" { t.setProtectedMode(.off); try testing.expect(!t.screen.cursor.protected); } + +test "Terminal: eraseLine simple erase right" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB", str); + } +} + +test "Terminal: eraseLine 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.eraseLine(.right, false); + 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("ABCDB", str); + } +} + +test "Terminal: eraseLine 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 } }).?; + try testing.expect(list_cell.row.wrap); + } + + 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); + } + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n123", str); + } +} + +test "Terminal: eraseLine right preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + 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); + } + } +} + +test "Terminal: eraseLine right wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB", str); + } +} + +test "Terminal: eraseLine right 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.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine right 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, 2); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: eraseLine right 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, 2); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: eraseLine right protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + for ("12345678") |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.eraseLine(.right, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123 X", str); + } +} + +test "Terminal: eraseLine simple erase left" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" DE", str); + } +} + +test "Terminal: eraseLine left 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.eraseLine(.left, false); + 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", str); + } +} + +test "Terminal: eraseLine left preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + 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); + } + } +} + +test "Terminal: eraseLine left wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" DE", str); + } +} + +test "Terminal: eraseLine left 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.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine left 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, 2); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseLine left 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, 2); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseLine left protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + 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.eraseLine(.left, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X 9", str); + } +} + +test "Terminal: eraseLine complete preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + 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); + } + } +} + +test "Terminal: eraseLine complete 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.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine complete 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, 2); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseLine complete 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, 2); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseLine complete protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + 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.eraseLine(.complete, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} From b139cb859794d2d4a3d9d54e09f5ad59ee7bbe19 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Feb 2024 22:17:42 -0800 Subject: [PATCH 091/428] terminal/new: bring in a bunch more tests --- src/terminal/Terminal.zig | 11 ++ src/terminal/new/Terminal.zig | 324 ++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 22f2590862..1966eb3303 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3158,6 +3158,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); @@ -7148,6 +7149,7 @@ test "Terminal: scrollDown outside of scroll region" { } } +// X test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -7174,6 +7176,7 @@ test "Terminal: scrollDown left/right scroll region" { } } +// X test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -7271,6 +7274,7 @@ test "Terminal: scrollUp top/bottom scroll region" { } } +// X test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -7338,6 +7342,7 @@ test "Terminal: scrollUp full top/bottom region" { } } +// X test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7358,6 +7363,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +// X test "Terminal: tabClear single" { const alloc = testing.allocator; var t = try init(alloc, 30, 5); @@ -7370,6 +7376,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); @@ -7381,6 +7388,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); @@ -7396,6 +7404,7 @@ test "Terminal: printRepeat simple" { } } +// X test "Terminal: printRepeat wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7411,6 +7420,7 @@ test "Terminal: printRepeat wrap" { } } +// X test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7501,6 +7511,7 @@ test "Terminal: DECCOLM resets scroll region" { 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); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 03d0e0a90f..b3f8dc35e1 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1709,6 +1709,91 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screen.setAttribute(attr); } +/// 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.style; + var attrs = [_]u8{0} ** 8; + var i: usize = 0; + + if (pen.flags.bold) { + attrs[i] = '1'; + i += 1; + } + + 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(); +} + /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. /// @@ -2654,6 +2739,26 @@ test "Terminal: horizontal tabs with left margin in origin mode" { } } +test "Terminal: horizontal tab back with cursor before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(5, 0); + try t.restoreCursor(); + try t.horizontalTabBack(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} + test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3381,6 +3486,33 @@ test "Terminal: scrollUp top/bottom scroll region" { } } +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); @@ -3420,6 +3552,26 @@ test "Terminal: scrollUp full top/bottom region" { } } +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); @@ -3471,6 +3623,60 @@ test "Terminal: scrollDown outside of scroll region" { } } +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); @@ -6216,3 +6422,121 @@ test "Terminal: eraseLine complete protected requested" { try testing.expectEqualStrings(" X", str); } } + +test "Terminal: tabClear single" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 5); + defer t.deinit(alloc); + + try t.horizontalTab(); + t.tabClear(.current); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); +} + +test "Terminal: tabClear all" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 5); + defer t.deinit(alloc); + + t.tabClear(.all); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); +} + +test "Terminal: printRepeat simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("A"); + try t.printRepeat(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AA", str); + } +} + +test "Terminal: printRepeat wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString(" A"); + try t.printRepeat(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A\nA", str); + } +} + +test "Terminal: printRepeat no previous character" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printRepeat(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: printAttributes" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + var storage: [64]u8 = undefined; + + { + try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;38:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.faint); + try t.setAttribute(.italic); + try t.setAttribute(.{ .underline = .single }); + try t.setAttribute(.blink); + try t.setAttribute(.inverse); + try t.setAttribute(.invisible); + try t.setAttribute(.strikethrough); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + } + + { + try t.setAttribute(.{ .underline = .single }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;4", buf); + } + + { + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0", buf); + } +} From 79146e3abde13f4d4c02ca676e0ab90d262c07ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 10:22:08 -0800 Subject: [PATCH 092/428] terminal/new: PageList respects max size, prunes scrollback --- src/terminal/new/PageList.zig | 204 +++++++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 15 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 7a1e463301..a971a170ed 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -50,6 +50,16 @@ page_pool: PagePool, /// The list of pages in the screen. pages: List, +/// Byte size of the total amount of allocated pages. Note this does +/// not include the total allocated amount in the pool which may be more +/// than this due to preheating. +page_size: usize, + +/// Maximum size of the page allocation in bytes. This only includes pages +/// that are used ONLY for scrollback. If the active area is still partially +/// in a page that also includes scrollback, then that page is not included. +max_size: usize, + /// The top-left of certain parts of the screen that are frequently /// accessed so we don't have to traverse the linked list to find them. /// @@ -85,14 +95,25 @@ pub const Viewport = union(enum) { /// Initialize the page. The top of the first page in the list is always the /// top of the active area of the screen (important knowledge for quickly /// setting up cursors in Screen). +/// +/// max_size is the maximum number of bytes that will be allocated for +/// pages. If this is smaller than the bytes required to show the viewport +/// then max_size will be ignored and the viewport will be shown, but no +/// scrollback will be created. max_size is always rounded down to the nearest +/// terminal page size (not virtual memory page), otherwise we would always +/// slightly exceed max_size in the limits. +/// +/// If max_size is null then there is no defined limit and the screen will +/// grow forever. In reality, the limit is set to the byte limit that your +/// computer can address in memory. If you somehow require more than that +/// (due to disk paging) then please contribute that yourself and perhaps +/// search deep within yourself to find out why you need that. pub fn init( alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt, - max_scrollback: usize, + max_size: ?usize, ) !PageList { - _ = max_scrollback; - // The screen starts with a single page that is the entire viewport, // and we'll split it thereafter if it gets too large and add more as // necessary. @@ -104,9 +125,13 @@ pub fn init( var page = try pool.create(); const page_buf = try page_pool.create(); - if (comptime std.debug.runtime_safety) @memset(page_buf, 0); // no errdefer because the pool deinit will clean these up + // In runtime safety modes we have to memset because the Zig allocator + // interface will always memset to 0xAA for undefined. In non-safe modes + // we use a page allocator and the OS guarantees zeroed memory. + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); + // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. page.* = .{ @@ -120,6 +145,16 @@ pub fn init( var page_list: List = .{}; page_list.prepend(page); + const page_size = page_buf.len; + + // The max size has to be adjusted to at least fit one viewport. + // We use item_size*2 because the active area can always span two + // pages as we scroll, otherwise we'd have to constantly copy in the + // small limit case. + const max_size_actual = @max( + max_size orelse std.math.maxInt(usize), + PagePool.item_size * 2, + ); return .{ .alloc = alloc, @@ -128,6 +163,8 @@ pub fn init( .pool = pool, .page_pool = page_pool, .pages = page_list, + .page_size = page_size, + .max_size = max_size_actual, .viewport = .{ .active = {} }, }; } @@ -216,6 +253,58 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } } +/// Grow the active area by exactly one row. +/// +/// This may allocate, but also may not if our current page has more +/// capacity we can use. This will prune scrollback if necessary to +/// adhere to max_size. +pub fn grow2(self: *PageList) !?*List.Node { + const last = self.pages.last.?; + if (last.data.capacity.rows > last.data.size.rows) { + // Fast path: we have capacity in the last page. + last.data.size.rows += 1; + return null; + } + + // Slower path: we have no space, we need to allocate a new page. + + // If allocation would exceed our max size, we prune the first page. + // We don't need to reallocate because we can simply reuse that first + // page. + if (self.page_size + PagePool.item_size > self.max_size) { + const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); + + // Get our first page and reset it to prepare for reuse. + const first = self.pages.popFirst().?; + assert(first != last); + const buf = first.data.memory; + @memset(buf, 0); + + // Initialize our new page and reinsert it as the last + first.data = Page.initBuf(OffsetBuf.init(buf), layout); + first.data.size.rows = 1; + self.pages.insertAfter(last, first); + + // In this case we do NOT need to update page_size because + // we're reusing an existing page so nothing has changed. + + return first; + } + + // We need to allocate a new memory buffer. + const next_page = try self.createPage(); + // we don't errdefer this because we've added it to the linked + // list and its fine to have dangling unused pages. + self.pages.append(next_page); + next_page.data.size.rows = 1; + + // Accounting + self.page_size += PagePool.item_size; + assert(self.page_size <= self.max_size); + + return next_page; +} + /// Grow the page list by exactly one page and return the new page. The /// newly allocated page will be size 0 (but capacity is set). pub fn grow(self: *PageList) !*List.Node { @@ -226,7 +315,8 @@ pub fn grow(self: *PageList) !*List.Node { return next_page; } -/// Create a new page node. This does not add it to the list. +/// Create a new page node. This does not add it to the list and this +/// does not do any memory size accounting with max_size/page_size. fn createPage(self: *PageList) !*List.Node { var page = try self.pool.create(); errdefer self.pool.destroy(page); @@ -507,17 +597,15 @@ const Cell = struct { /// this file then consider a different approach and ask yourself very /// carefully if you really need this. pub fn screenPoint(self: Cell) point.Point { - var x: usize = self.col_idx; var y: usize = self.row_idx; var page = self.page; while (page.prev) |prev| { - x += prev.data.size.cols; y += prev.data.size.rows; page = prev; } return .{ .screen = .{ - .x = x, + .x = self.col_idx, .y = y, } }; } @@ -527,7 +615,7 @@ test "PageList" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try testing.expect(s.viewport == .active); try testing.expect(s.pages.first != null); @@ -544,7 +632,7 @@ test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); @@ -579,7 +667,7 @@ test "PageList scroll top" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try s.growRows(10); @@ -624,7 +712,7 @@ test "PageList scroll delta row back" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try s.growRows(10); @@ -660,7 +748,7 @@ test "PageList scroll delta row back overflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try s.growRows(10); @@ -696,7 +784,7 @@ test "PageList scroll delta row forward" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); try s.growRows(10); @@ -733,7 +821,7 @@ test "PageList scroll delta row forward into active" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, 80, 24, null); defer s.deinit(); s.scroll(.{ .delta_row = 2 }); @@ -746,3 +834,89 @@ test "PageList scroll delta row forward into active" { } }, pt); } } + +test "PageList grow fit in capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // So we know we're using capacity to grow + const last = &s.pages.last.?.data; + try testing.expect(last.size.rows < last.capacity.rows); + + // Grow + try testing.expect(try s.grow2() == null); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, pt); + } +} + +test "PageList grow allocate" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const last_node = s.pages.last.?; + const last = &s.pages.last.?.data; + for (0..last.capacity.rows - last.size.rows) |_| { + try testing.expect(try s.grow2() == null); + } + + // Grow, should allocate + const new = (try s.grow2()).?; + try testing.expect(s.pages.last.? == new); + try testing.expect(last_node.next.? == new); + { + const cell = s.getCell(.{ .active = .{ .y = s.rows - 1 } }).?; + try testing.expect(cell.page == new); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = last.capacity.rows, + } }, cell.screenPoint()); + } +} + +test "PageList grow prune scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow2() == null); + } + + // Grow and allocate one more page. Then fill that page up. + const page2_node = (try s.grow2()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try s.grow2() == null); + } + + // Get our page size + const old_page_size = s.page_size; + + // Next should create a new page, but it should reuse our first + // page since we're at max size. + const new = (try s.grow2()).?; + try testing.expect(s.pages.last.? == new); + try testing.expectEqual(s.page_size, old_page_size); + + // Our first should now be page2 and our last should be page1 + try testing.expectEqual(page2_node, s.pages.first.?); + try testing.expectEqual(page1_node, s.pages.last.?); +} From 345b246e06cd7089767471c96a018a905517c244 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 10:29:12 -0800 Subject: [PATCH 093/428] terminal/new: use new pagelist grow mechanism that prunes --- src/terminal/new/PageList.zig | 30 +++++++++++------------------- src/terminal/new/Screen.zig | 33 ++++++++------------------------- 2 files changed, 19 insertions(+), 44 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index a971a170ed..190cf93205 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -258,7 +258,9 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { /// This may allocate, but also may not if our current page has more /// capacity we can use. This will prune scrollback if necessary to /// adhere to max_size. -pub fn grow2(self: *PageList) !?*List.Node { +/// +/// This returns the newly allocated page node if there is one. +pub fn grow(self: *PageList) !?*List.Node { const last = self.pages.last.?; if (last.data.capacity.rows > last.data.size.rows) { // Fast path: we have capacity in the last page. @@ -305,16 +307,6 @@ pub fn grow2(self: *PageList) !?*List.Node { return next_page; } -/// Grow the page list by exactly one page and return the new page. The -/// newly allocated page will be size 0 (but capacity is set). -pub fn grow(self: *PageList) !*List.Node { - const next_page = try self.createPage(); - // we don't errdefer this because we've added it to the linked - // list and its fine to have dangling unused pages. - self.pages.append(next_page); - return next_page; -} - /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. fn createPage(self: *PageList) !*List.Node { @@ -461,7 +453,7 @@ fn growRows(self: *PageList, n: usize) !void { } while (n_rem > 0) { - page = try self.grow(); + page = (try self.grow()).?; const add = @min(n_rem, page.data.capacity.rows); page.data.size.rows = add; n_rem -= add; @@ -847,7 +839,7 @@ test "PageList grow fit in capacity" { try testing.expect(last.size.rows < last.capacity.rows); // Grow - try testing.expect(try s.grow2() == null); + try testing.expect(try s.grow() == null); { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -868,11 +860,11 @@ test "PageList grow allocate" { const last_node = s.pages.last.?; const last = &s.pages.last.?.data; for (0..last.capacity.rows - last.size.rows) |_| { - try testing.expect(try s.grow2() == null); + try testing.expect(try s.grow() == null); } // Grow, should allocate - const new = (try s.grow2()).?; + const new = (try s.grow()).?; try testing.expect(s.pages.last.? == new); try testing.expect(last_node.next.? == new); { @@ -897,14 +889,14 @@ test "PageList grow prune scrollback" { const page1_node = s.pages.last.?; const page1 = page1_node.data; for (0..page1.capacity.rows - page1.size.rows) |_| { - try testing.expect(try s.grow2() == null); + try testing.expect(try s.grow() == null); } // Grow and allocate one more page. Then fill that page up. - const page2_node = (try s.grow2()).?; + const page2_node = (try s.grow()).?; const page2 = page2_node.data; for (0..page2.capacity.rows - page2.size.rows) |_| { - try testing.expect(try s.grow2() == null); + try testing.expect(try s.grow() == null); } // Get our page size @@ -912,7 +904,7 @@ test "PageList grow prune scrollback" { // Next should create a new page, but it should reuse our first // page since we're at max size. - const new = (try s.grow2()).?; + const new = (try s.grow()).?; try testing.expect(s.pages.last.? == new); try testing.expectEqual(s.page_size, old_page_size); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 075c51743f..f45ae46315 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -226,31 +226,14 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) pub fn cursorDownScroll(self: *Screen) !void { assert(self.cursor.y == self.pages.rows - 1); - const cursor_page = self.cursor.page_offset.page; - if (cursor_page.data.capacity.rows > cursor_page.data.size.rows) { - // If we have cap space in our current cursor page then we can take - // a fast path: update the size, recalculate the row/cell cursor pointers. - cursor_page.data.size.rows += 1; - - const page_offset = self.cursor.page_offset.forward(1).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - } else { - // No space, we need to allocate a new page and move the cursor to it. - const new_page = try self.pages.grow(); - assert(new_page.data.size.rows == 0); - new_page.data.size.rows = 1; - const page_offset: PageList.RowOffset = .{ - .page = new_page, - .row_offset = 0, - }; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - } + // Grow our pages by one row. The PageList will handle if we need to + // allocate, prune scrollback, whatever. + _ = try self.pages.grow(); + const page_offset = self.cursor.page_offset.forward(1).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; // The newly created line needs to be styled according to the bg color // if it is set. From 998320f32a0230a638b5e051a6b654ad7cfc4fcf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 14:05:56 -0800 Subject: [PATCH 094/428] terminal/new: pagelist rowChunkIterator --- src/terminal/new/PageList.zig | 131 ++++++++++++++++++++++++++++++++++ src/terminal/new/Screen.zig | 12 ++++ 2 files changed, 143 insertions(+) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 190cf93205..afdecfb03e 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -392,6 +392,80 @@ pub fn rowIterator( return .{ .row = tl.forward(tl_pt.coord().y) }; } +pub const RowChunkIterator = struct { + row: ?RowOffset = null, + limit: ?usize = null, + + pub fn next(self: *RowChunkIterator) ?Chunk { + // Get our current row location + const row = self.row orelse return null; + + // If we have a limit, the + if (self.limit) |*limit| { + assert(limit.* > 0); // should be handled already + const len = @min(row.page.data.size.rows - row.row_offset, limit.*); + if (len > limit.*) { + self.row = row.forward(len); + limit.* -= len; + } else { + self.row = null; + } + + return .{ + .page = row.page, + .start = row.row_offset, + .end = row.row_offset + len, + }; + } + + // If we have no limit, then we consume this entire page. Our + // next row is the next page. + self.row = next: { + const next_page = row.page.next orelse break :next null; + break :next .{ .page = next_page }; + }; + + return .{ + .page = row.page, + .start = row.row_offset, + .end = row.page.data.size.rows, + }; + } + + pub const Chunk = struct { + page: *List.Node, + start: usize, + end: usize, + }; +}; + +/// Return an iterator that iterates through the rows in the tagged area +/// of the point. The iterator returns row "chunks", which are the largest +/// contiguous set of rows in a single backing page for a given portion of +/// the point region. +/// +/// This is a more efficient way to iterate through the data in a region, +/// since you can do simple pointer math and so on. +pub fn rowChunkIterator( + self: *const PageList, + tl_pt: point.Point, +) RowChunkIterator { + const tl = self.getTopLeft(tl_pt); + const limit: ?usize = switch (tl_pt) { + // These always go to the end of the screen. + .screen, .active => null, + + // Viewport always is rows long + .viewport => self.rows, + + // History goes to the top of the active area. This is more expensive + // to calculate but also more rare of a thing to iterate over. + .history => @panic("TODO"), + }; + + return .{ .row = tl.forward(tl_pt.coord().y), .limit = limit }; +} + /// Get the top-left of the screen for the given tag. fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { return switch (tag) { @@ -912,3 +986,60 @@ test "PageList grow prune scrollback" { try testing.expectEqual(page2_node, s.pages.first.?); try testing.expectEqual(page1_node, s.pages.last.?); } + +test "PageList rowChunkIterator single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // The viewport should be within a single page + try testing.expect(s.pages.first.?.next == null); + + // Iterate the active area + var it = s.rowChunkIterator(.{ .active = .{} }); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + try testing.expectEqual(@as(usize, 0), chunk.start); + try testing.expectEqual(@as(usize, s.rows), chunk.end); + } + + // Should only have one chunk + try testing.expect(it.next() == null); +} + +test "PageList rowChunkIterator two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.rowChunkIterator(.{ .active = .{} }); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start = chunk.page.data.size.rows - s.rows + 1; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(chunk.page.data.size.rows, chunk.end); + } + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.last.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(start + 1, chunk.end); + } + try testing.expect(it.next() == null); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index f45ae46315..3b5d6ec6f2 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -12,6 +12,7 @@ const point = @import("point.zig"); const size = @import("size.zig"); const style = @import("style.zig"); const Page = pagepkg.Page; +const Cell = pagepkg.Cell; /// The general purpose allocator to use for all memory allocations. /// Unfortunately some screen operations do require allocation. @@ -265,6 +266,17 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { } } +/// Erase the active area of the screen from y=0 to rows-1. The cells +/// are blanked using the given blank cell. +pub fn eraseActive(self: *Screen, blank: Cell) void { + // We use rowIterator because it handles the case where the active + // area spans multiple underlying pages. This is slightly slower to + // calculate but erasing isn't a high-frequency operation. We can + // optimize this later, too. + _ = self; + _ = blank; +} + /// Set a style attribute for the current cursor. /// /// This can cause a page split if the current page cannot fit this style. From a8b1498a2be672239a51fc5a0921ed45b02c34e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 14:31:35 -0800 Subject: [PATCH 095/428] terminal/new: screen has more logic, eraseActive --- src/terminal/new/PageList.zig | 6 ++ src/terminal/new/Screen.zig | 186 +++++++++++++++++++++++++++++++--- src/terminal/new/Terminal.zig | 77 ++------------ 3 files changed, 190 insertions(+), 79 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index afdecfb03e..2fc02005b6 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -12,6 +12,7 @@ const stylepkg = @import("style.zig"); const size = @import("size.zig"); const OffsetBuf = size.OffsetBuf; const Page = pagepkg.Page; +const Row = pagepkg.Row; /// The number of PageList.Nodes we preheat the pool with. A node is /// a very small struct so we can afford to preheat many, but the exact @@ -436,6 +437,11 @@ pub const RowChunkIterator = struct { page: *List.Node, start: usize, end: usize, + + pub fn rows(self: Chunk) []Row { + const rows_ptr = self.page.data.rows.ptr(self.page.data.memory); + return rows_ptr[self.start..self.end]; + } }; }; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 3b5d6ec6f2..d0efdb5e48 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -12,6 +12,7 @@ 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; /// The general purpose allocator to use for all memory allocations. @@ -247,6 +248,16 @@ pub fn cursorDownScroll(self: *Screen) !void { } } +/// 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. @@ -268,13 +279,77 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { /// Erase the active area of the screen from y=0 to rows-1. The cells /// are blanked using the given blank cell. -pub fn eraseActive(self: *Screen, blank: Cell) void { - // We use rowIterator because it handles the case where the active - // area spans multiple underlying pages. This is slightly slower to - // calculate but erasing isn't a high-frequency operation. We can - // optimize this later, too. - _ = self; - _ = blank; +pub fn eraseActive(self: *Screen) void { + var it = self.pages.rowChunkIterator(.{ .active = .{} }); + 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]; + + // Erase all cells + self.eraseCells(&chunk.page.data, row, cells); + + // Reset our row to point to the proper memory but everything + // else is zeroed. + row.* = .{ .cells = cells_offset }; + } + } +} + +/// Erase the cells with the blank cell. This takes care to handle +/// cleaning up graphemes and styles. +pub fn eraseCells( + 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()); +} + +/// 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 .{}; } /// Set a style attribute for the current cursor. @@ -527,10 +602,20 @@ pub fn dumpStringAlloc( 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. 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); + continue; + } + if (self.cursor.x == self.pages.cols) { @panic("wrap not implemented"); } @@ -543,12 +628,20 @@ fn testWriteString(self: *Screen, text: []const u8) !void { assert(width == 1 or width == 2); switch (width) { 1 => { - self.cursor.page_cell.content_tag = .codepoint; - self.cursor.page_cell.content = .{ .codepoint = c }; - self.cursor.x += 1; - if (self.cursor.x < self.pages.cols) { - const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - self.cursor.page_cell = @ptrCast(cell + 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; + } + + if (self.cursor.x + 1 < self.pages.cols) { + self.cursorRight(1); } else { @panic("wrap not implemented"); } @@ -574,6 +667,20 @@ test "Screen read and write" { 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 style basics" { const testing = std.testing; const alloc = testing.allocator; @@ -635,3 +742,56 @@ test "Screen style reset with unset" { try testing.expect(s.cursor.style_id == 0); try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } + +test "Screen eraseActive 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.eraseActive(); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); +} + +test "Screen eraseActive 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.eraseActive(); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); +} + +test "Screen eraseActive 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_offset.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + s.eraseActive(); + + // 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); +} diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b3f8dc35e1..b9a9ac5901 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1192,7 +1192,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.blankCells(page, row, cells_write); + self.screen.eraseCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1286,7 +1286,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.blankCells(page, row, cells_write); + self.screen.eraseCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1350,7 +1350,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // it to be empty so we don't split the multi-cell char. const end: *Cell = @ptrCast(x); if (end.wide == .wide) { - self.blankCells(page, self.screen.cursor.page_row, end[0..1]); + self.screen.eraseCells(page, self.screen.cursor.page_row, end[0..1]); } // We work backwards so we don't overwrite data. @@ -1380,7 +1380,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.blankCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); + self.screen.eraseCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); } /// Removes amount characters from the current cursor position to the right. @@ -1410,7 +1410,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { // 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.blankCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + self.screen.eraseCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); } // Remaining cols from our cursor to the right margin. @@ -1433,7 +1433,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { if (end.wide == .spacer_tail) { const wide: [*]Cell = right + count - 1; assert(wide[0].wide == .wide); - self.blankCells(page, self.screen.cursor.page_row, wide[0..2]); + self.screen.eraseCells(page, self.screen.cursor.page_row, wide[0..2]); } while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { @@ -1462,7 +1462,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.blankCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); + self.screen.eraseCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); } pub fn eraseChars(self: *Terminal, count_req: usize) void { @@ -1497,7 +1497,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // 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.blankCells( + self.screen.eraseCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cells[0..end], @@ -1512,7 +1512,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { const cell_multi: [*]Cell = @ptrCast(cells + x); const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; - self.blankCells( + self.screen.eraseCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cell_multi[0..1], @@ -1582,7 +1582,7 @@ pub fn eraseLine( // If we're not respecting protected attributes, we can use a fast-path // to fill the entire line. if (!protected) { - self.blankCells( + self.screen.eraseCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cells[start..end], @@ -1594,7 +1594,7 @@ pub fn eraseLine( const cell_multi: [*]Cell = @ptrCast(cells + x); const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; - self.blankCells( + self.screen.eraseCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cell_multi[0..1], @@ -1602,54 +1602,6 @@ pub fn eraseLine( } } -/// Blank the given cells. The cells must be long to the given row and page. -/// This will handle refcounted styles properly as well as graphemes. -fn blankCells( - self: *const Terminal, - 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.screen.cursor.style_id) { - self.screen.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.cols) row.styled = false; - } - - @memset(cells, self.blankCell()); -} - /// Resets all margins and fills the whole screen with the character 'E' /// /// Sets the cursor to the top left corner. @@ -1802,13 +1754,6 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); } -/// Returns the blank cell to use when doing terminal operations that -/// require preserving the bg color. -fn blankCell(self: *const Terminal) Cell { - if (self.screen.cursor.style_id == style.default_id) return .{}; - return self.screen.cursor.style.bgCell() orelse .{}; -} - test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, 40, 40); From 55b34251ac1f224178ceed35f548aecbbc3e6d87 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 17:11:05 -0800 Subject: [PATCH 096/428] terminal/new: pagelist can iterate over history --- src/terminal/new/PageList.zig | 126 +++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 2fc02005b6..b17a460b3b 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -395,41 +395,77 @@ pub fn rowIterator( pub const RowChunkIterator = struct { row: ?RowOffset = null, - limit: ?usize = null, + limit: Limit = .none, + + const Limit = union(enum) { + none, + count: usize, + row: RowOffset, + }; pub fn next(self: *RowChunkIterator) ?Chunk { // Get our current row location const row = self.row orelse return null; - // If we have a limit, the - if (self.limit) |*limit| { - assert(limit.* > 0); // should be handled already - const len = @min(row.page.data.size.rows - row.row_offset, limit.*); - if (len > limit.*) { - self.row = row.forward(len); - limit.* -= len; - } else { - self.row = null; - } + return switch (self.limit) { + .none => none: { + // If we have no limit, then we consume this entire page. Our + // next row is the next page. + self.row = next: { + const next_page = row.page.next orelse break :next null; + break :next .{ .page = next_page }; + }; - return .{ - .page = row.page, - .start = row.row_offset, - .end = row.row_offset + len, - }; - } + break :none .{ + .page = row.page, + .start = row.row_offset, + .end = row.page.data.size.rows, + }; + }, + + .count => |*limit| count: { + assert(limit.* > 0); // should be handled already + const len = @min(row.page.data.size.rows - row.row_offset, limit.*); + if (len > limit.*) { + self.row = row.forward(len); + limit.* -= len; + } else { + self.row = null; + } - // If we have no limit, then we consume this entire page. Our - // next row is the next page. - self.row = next: { - const next_page = row.page.next orelse break :next null; - break :next .{ .page = next_page }; - }; + break :count .{ + .page = row.page, + .start = row.row_offset, + .end = row.row_offset + len, + }; + }, + + .row => |limit_row| row: { + // If this is not the same page as our limit then we + // can consume the entire page. + if (limit_row.page != row.page) { + self.row = next: { + const next_page = row.page.next orelse break :next null; + break :next .{ .page = next_page }; + }; + + break :row .{ + .page = row.page, + .start = row.row_offset, + .end = row.page.data.size.rows, + }; + } - return .{ - .page = row.page, - .start = row.row_offset, - .end = row.page.data.size.rows, + // If this is the same page then we only consume up to + // the limit row. + self.row = null; + if (row.row_offset > limit_row.row_offset) return null; + break :row .{ + .page = row.page, + .start = row.row_offset, + .end = limit_row.row_offset + 1, + }; + }, }; } @@ -457,16 +493,16 @@ pub fn rowChunkIterator( tl_pt: point.Point, ) RowChunkIterator { const tl = self.getTopLeft(tl_pt); - const limit: ?usize = switch (tl_pt) { + const limit: RowChunkIterator.Limit = switch (tl_pt) { // These always go to the end of the screen. - .screen, .active => null, + .screen, .active => .{ .none = {} }, // Viewport always is rows long - .viewport => self.rows, + .viewport => .{ .count = self.rows }, // History goes to the top of the active area. This is more expensive // to calculate but also more rare of a thing to iterate over. - .history => @panic("TODO"), + .history => .{ .row = self.getTopLeft(.active) }, }; return .{ .row = tl.forward(tl_pt.coord().y), .limit = limit }; @@ -1049,3 +1085,31 @@ test "PageList rowChunkIterator two pages" { } try testing.expect(it.next() == null); } + +test "PageList rowChunkIterator history two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.rowChunkIterator(.{ .history = .{} }); + { + const active_tl = s.getTopLeft(.active); + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(active_tl.row_offset + 1, chunk.end); + } + try testing.expect(it.next() == null); +} From 6e0df767cff9294a14b31b9b5895a3757a9764bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 17:21:31 -0800 Subject: [PATCH 097/428] terminal/new: eraseRows --- src/terminal/new/PageList.zig | 37 ++++++++++++++++++++++++----------- src/terminal/new/Screen.zig | 35 +++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index b17a460b3b..5fbd7c480a 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -488,21 +488,36 @@ pub const RowChunkIterator = struct { /// /// This is a more efficient way to iterate through the data in a region, /// since you can do simple pointer math and so on. +/// +/// If bl_pt is non-null, iteration will stop at the bottom left point +/// (inclusive). If bl_pt is null, the entire region specified by the point +/// tag will be iterated over. tl_pt and bl_pt must be the same tag, and +/// bl_pt must be greater than or equal to tl_pt. pub fn rowChunkIterator( self: *const PageList, tl_pt: point.Point, + bl_pt: ?point.Point, ) RowChunkIterator { + // TODO: bl_pt assertions + const tl = self.getTopLeft(tl_pt); - const limit: RowChunkIterator.Limit = switch (tl_pt) { - // These always go to the end of the screen. - .screen, .active => .{ .none = {} }, + const limit: RowChunkIterator.Limit = limit: { + if (bl_pt) |pt| { + const bl = self.getTopLeft(pt); + break :limit .{ .row = bl.forward(pt.coord().y).? }; + } - // Viewport always is rows long - .viewport => .{ .count = self.rows }, + break :limit switch (tl_pt) { + // These always go to the end of the screen. + .screen, .active => .{ .none = {} }, - // History goes to the top of the active area. This is more expensive - // to calculate but also more rare of a thing to iterate over. - .history => .{ .row = self.getTopLeft(.active) }, + // Viewport always is rows long + .viewport => .{ .count = self.rows }, + + // History goes to the top of the active area. This is more expensive + // to calculate but also more rare of a thing to iterate over. + .history => .{ .row = self.getTopLeft(.active) }, + }; }; return .{ .row = tl.forward(tl_pt.coord().y), .limit = limit }; @@ -1040,7 +1055,7 @@ test "PageList rowChunkIterator single page" { try testing.expect(s.pages.first.?.next == null); // Iterate the active area - var it = s.rowChunkIterator(.{ .active = .{} }); + var it = s.rowChunkIterator(.{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -1068,7 +1083,7 @@ test "PageList rowChunkIterator two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.rowChunkIterator(.{ .active = .{} }); + var it = s.rowChunkIterator(.{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -1102,7 +1117,7 @@ test "PageList rowChunkIterator history two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.rowChunkIterator(.{ .history = .{} }); + var it = s.rowChunkIterator(.{ .history = .{} }, null); { const active_tl = s.getTopLeft(.active); const chunk = it.next().?; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index d0efdb5e48..e57d8111ca 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -277,10 +277,11 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { } } -/// Erase the active area of the screen from y=0 to rows-1. The cells -/// are blanked using the given blank cell. -pub fn eraseActive(self: *Screen) void { - var it = self.pages.rowChunkIterator(.{ .active = .{} }); +// Erase the region specified by tl and bl, inclusive. Erased cells are +// colored with the current style background color. This will erase all +// cells in the rows. +pub fn eraseRows(self: *Screen, tl: point.Point, bl: ?point.Point) void { + var it = self.pages.rowChunkIterator(tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_offset = row.cells; @@ -345,6 +346,20 @@ pub fn eraseCells( @memset(cells, self.blankCell()); } +/// Erase cells but only if they are not protected. +pub fn eraseUnprotectedCells( + self: *Screen, + page: *Page, + row: *Row, + cells: []Cell, +) void { + for (cells) |*cell| { + if (cell.protected) continue; + const cell_multi: [*]Cell = @ptrCast(cell); + self.eraseCells(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 { @@ -743,7 +758,7 @@ test "Screen style reset with unset" { try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -test "Screen eraseActive one line" { +test "Screen eraseRows active one line" { const testing = std.testing; const alloc = testing.allocator; @@ -751,13 +766,13 @@ test "Screen eraseActive one line" { defer s.deinit(); try s.testWriteString("hello, world"); - s.eraseActive(); + s.eraseRows(.{ .active = .{} }, null); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); } -test "Screen eraseActive multi line" { +test "Screen eraseRows active multi line" { const testing = std.testing; const alloc = testing.allocator; @@ -765,13 +780,13 @@ test "Screen eraseActive multi line" { defer s.deinit(); try s.testWriteString("hello\nworld"); - s.eraseActive(); + s.eraseRows(.{ .active = .{} }, null); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); } -test "Screen eraseActive styled line" { +test "Screen eraseRows active styled line" { const testing = std.testing; const alloc = testing.allocator; @@ -786,7 +801,7 @@ test "Screen eraseActive styled line" { const page = s.cursor.page_offset.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - s.eraseActive(); + s.eraseRows(.{ .active = .{} }, null); // We should have none because active cleared it try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); From f7e286853372a880c1c159a71fe828e7102442b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 17:32:08 -0800 Subject: [PATCH 098/428] terminal/new: erasedisplay wip --- src/terminal/Terminal.zig | 8 + src/terminal/new/Screen.zig | 22 ++- src/terminal/new/Terminal.zig | 328 ++++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 5 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1966eb3303..418d7ad153 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6073,6 +6073,7 @@ test "Terminal: eraseLine complete protected requested" { } } +// X test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6095,6 +6096,7 @@ test "Terminal: eraseDisplay simple erase below" { } } +// X test "Terminal: eraseDisplay erase below preserves SGR bg" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6127,6 +6129,7 @@ test "Terminal: eraseDisplay erase below preserves SGR bg" { } } +// X test "Terminal: eraseDisplay below split multi-cell" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6149,6 +6152,7 @@ test "Terminal: eraseDisplay below split multi-cell" { } } +// X test "Terminal: eraseDisplay below protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6172,6 +6176,7 @@ test "Terminal: eraseDisplay below protected attributes respected with iso" { } } +// X test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6197,6 +6202,7 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec most re } } +// X test "Terminal: eraseDisplay below protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6220,6 +6226,7 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec set" { } } +// X test "Terminal: eraseDisplay simple erase above" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6242,6 +6249,7 @@ test "Terminal: eraseDisplay simple erase above" { } } +// X test "Terminal: eraseDisplay below protected attributes respected with force" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index e57d8111ca..1b506fecd5 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -280,7 +280,15 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { // Erase the region specified by tl and bl, inclusive. Erased cells are // colored with the current style background color. This will erase all // cells in the rows. -pub fn eraseRows(self: *Screen, tl: point.Point, bl: ?point.Point) void { +// +// If protected is true, the protected flag will be respected and only +// unprotected cells will be erased. Otherwise, all cells will be erased. +pub fn eraseRows( + self: *Screen, + tl: point.Point, + bl: ?point.Point, + protected: bool, +) void { var it = self.pages.rowChunkIterator(tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { @@ -289,7 +297,11 @@ pub fn eraseRows(self: *Screen, tl: point.Point, bl: ?point.Point) void { const cells = cells_multi[0..self.pages.cols]; // Erase all cells - self.eraseCells(&chunk.page.data, row, cells); + if (protected) { + self.eraseUnprotectedCells(&chunk.page.data, row, cells); + } else { + self.eraseCells(&chunk.page.data, row, cells); + } // Reset our row to point to the proper memory but everything // else is zeroed. @@ -766,7 +778,7 @@ test "Screen eraseRows active one line" { defer s.deinit(); try s.testWriteString("hello, world"); - s.eraseRows(.{ .active = .{} }, null); + s.eraseRows(.{ .active = .{} }, null, false); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); @@ -780,7 +792,7 @@ test "Screen eraseRows active multi line" { defer s.deinit(); try s.testWriteString("hello\nworld"); - s.eraseRows(.{ .active = .{} }, null); + s.eraseRows(.{ .active = .{} }, null, false); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); @@ -801,7 +813,7 @@ test "Screen eraseRows active styled line" { const page = s.cursor.page_offset.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - s.eraseRows(.{ .active = .{} }, null); + s.eraseRows(.{ .active = .{} }, null, false); // We should have none because active cleared it try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b9a9ac5901..183edcfd64 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1602,6 +1602,137 @@ pub fn eraseLine( } } +/// 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.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); + + // All lines below + if (self.screen.cursor.y + 1 < self.rows) { + self.screen.eraseRows( + .{ .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.eraseRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + protected, + ); + } + + // Unsets pending wrap state + assert(!self.screen.cursor.pending_wrap); + }, + // + // .scrollback => self.screen.clear(.history) catch |err| { + // // This isn't a huge issue, so just log it. + // log.err("failed to clear scrollback: {}", .{err}); + // }, + + else => @panic("TODO"), + } +} + /// Resets all margins and fills the whole screen with the character 'E' /// /// Sets the cursor to the top left corner. @@ -6485,3 +6616,200 @@ test "Terminal: printAttributes" { try testing.expectEqualStrings("0", buf); } } + +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(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(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); + } +} From 116f6264ba4f6cabb65a024018695266b451ca66 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 19:03:43 -0800 Subject: [PATCH 099/428] terminal/new: erase display complete --- src/terminal/Terminal.zig | 14 ++ src/terminal/new/Terminal.zig | 350 ++++++++++++++++++++++++++++------ 2 files changed, 302 insertions(+), 62 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 418d7ad153..d68983ad65 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -6273,6 +6273,7 @@ test "Terminal: eraseDisplay below protected attributes respected with force" { } } +// X test "Terminal: eraseDisplay erase above preserves SGR bg" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6305,6 +6306,7 @@ test "Terminal: eraseDisplay erase above preserves SGR bg" { } } +// X test "Terminal: eraseDisplay above split multi-cell" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6327,6 +6329,7 @@ test "Terminal: eraseDisplay above split multi-cell" { } } +// X test "Terminal: eraseDisplay above protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6350,6 +6353,7 @@ test "Terminal: eraseDisplay above protected attributes respected with iso" { } } +// X test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6375,6 +6379,7 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec most re } } +// X test "Terminal: eraseDisplay above protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6398,6 +6403,7 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec set" { } } +// X test "Terminal: eraseDisplay above protected attributes respected with force" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6420,6 +6426,8 @@ test "Terminal: eraseDisplay above protected attributes respected with force" { 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); @@ -6459,6 +6467,7 @@ test "Terminal: eraseDisplay above" { 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); @@ -6492,6 +6501,7 @@ test "Terminal: eraseDisplay below" { 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); @@ -6536,6 +6546,7 @@ test "Terminal: eraseDisplay complete" { try testing.expect(!cell.attrs.bold); } +// X test "Terminal: eraseDisplay protected complete" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6558,6 +6569,7 @@ test "Terminal: eraseDisplay protected complete" { } } +// X test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6580,6 +6592,7 @@ test "Terminal: eraseDisplay protected below" { } } +// X test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6597,6 +6610,7 @@ test "Terminal: eraseDisplay protected above" { } } +// X test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; var t = try init(alloc, 10, 3); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 183edcfd64..b13b2d7861 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1627,68 +1627,58 @@ pub fn eraseDisplay( // // 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 }); - // }, + + .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.eraseRows( + .{ .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) @@ -6813,3 +6803,239 @@ test "Terminal: eraseDisplay simple erase above" { 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); + } +} From 1d30577506da009e699afda849afa2cddad24a24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 19:22:32 -0800 Subject: [PATCH 100/428] terminal/new: scroll clear --- src/terminal/new/PageList.zig | 63 +++++++++++++++++++++++++++++++++++ src/terminal/new/Screen.zig | 7 ++++ src/terminal/new/Terminal.zig | 59 ++++++++++++++++---------------- src/terminal/new/page.zig | 13 ++++++++ 4 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 5fbd7c480a..06e5ede30b 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -254,6 +254,36 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } } +/// Clear the screen by scrolling written contents up into the scrollback. +/// This will not update the viewport. +pub fn scrollClear(self: *PageList) !void { + // Go through the active area backwards to find the first non-empty + // row. We use this to determine how many rows to scroll up. + const non_empty: usize = non_empty: { + var page = self.pages.last.?; + var n: usize = 0; + while (true) { + const rows: [*]Row = page.data.rows.ptr(page.data.memory); + for (0..page.data.size.rows) |i| { + const rev_i = page.data.size.rows - i - 1; + const row = rows[rev_i]; + const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + for (cells) |cell| { + if (!cell.isEmpty()) break :non_empty self.rows - n; + } + + n += 1; + if (n > self.rows) break :non_empty 0; + } + + page = page.prev orelse break :non_empty 0; + } + }; + + // Scroll + for (0..non_empty) |_| _ = try self.grow(); +} + /// Grow the active area by exactly one row. /// /// This may allocate, but also may not if our current page has more @@ -958,6 +988,39 @@ test "PageList scroll delta row forward into active" { } } +test "PageList scroll clear" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + { + const cell = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + cell.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + { + const cell = s.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + cell.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + try s.scrollClear(); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } +} + test "PageList grow fit in capacity" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 1b506fecd5..7979908ba6 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -277,6 +277,13 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { } } +/// 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.cursorAbsolute(0, 0); +} + // Erase the region specified by tl and bl, inclusive. Erased cells are // colored with the current style background color. This will erase all // cells in the rows. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b13b2d7861..627a3bcb38 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1614,19 +1614,20 @@ pub fn eraseDisplay( 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 }); - // }, + .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 @@ -7001,22 +7002,22 @@ test "Terminal: eraseDisplay protected below" { } } -// 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 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; diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index a3cb6305e9..ca89fed625 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -599,6 +599,19 @@ pub const Cell = packed struct(u64) { }; } + /// Returns true if the cell has no text or styling. + pub fn isEmpty(self: Cell) bool { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => !self.hasText(), + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + pub fn hasGrapheme(self: Cell) bool { return self.content_tag == .codepoint_grapheme; } From 6b5682021e2b4b711302ae4447bee04356d0fcb3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 19:55:34 -0800 Subject: [PATCH 101/428] terminal/new: PageList.erase --- src/terminal/new/PageList.zig | 92 ++++++++++++++++++++++++++++++++++- src/terminal/new/Screen.zig | 15 ++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 06e5ede30b..102093beb7 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -359,6 +359,63 @@ fn createPage(self: *PageList) !*List.Node { return page; } +/// Erase the rows from the given top to bottom (inclusive). Erasing +/// the rows doesn't clear them but actually physically REMOVES the rows. +/// If the top or bottom point is in the middle of a page, the other +/// contents in the page will be preserved but the page itself will be +/// underutilized (size < capacity). +pub fn erase( + self: *PageList, + tl_pt: point.Point, + bl_pt: ?point.Point, +) void { + // A rowChunkIterator iterates one page at a time from the back forward. + // "back" here is in terms of scrollback, but actually the front of the + // linked list. + var it = self.rowChunkIterator(tl_pt, bl_pt); + while (it.next()) |chunk| { + // If the chunk is a full page, deinit thit page and remove it from + // the linked list. + if (chunk.fullPage()) { + self.erasePage(chunk.page); + continue; + } + + // The chunk is not a full page so we need to move the rows. + // This is a cheap operation because we're just moving cell offsets, + // not the actual cell contents. + assert(chunk.start == 0); + const rows = chunk.page.data.rows.ptr(chunk.page.data.memory); + const scroll_amount = chunk.page.data.size.rows - chunk.end; + for (0..scroll_amount) |i| { + const src: *Row = &rows[i + scroll_amount]; + const dst: *Row = &rows[i]; + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + + // We don't even bother deleting the data in the swapped rows + // because erasing in this way yields a page that likely will never + // be written to again (its in the past) or it will grow and the + // terminal erase will automatically erase the data. + + chunk.page.data.size.rows = @intCast(scroll_amount); + } +} + +/// Erase a single page, freeing all its resources. The page can be +/// anywhere in the linked list. +fn erasePage(self: *PageList, page: *List.Node) void { + // Remove the page from the linked list + self.pages.remove(page); + + // Reset the page memory and return it back to the pool. + @memset(page.data.memory, 0); + self.page_pool.destroy(@ptrCast(page.data.memory.ptr)); + self.pool.destroy(page); +} + /// Get the top-left of the screen for the given tag. pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { // TODO: assert the point is valid @@ -508,6 +565,11 @@ pub const RowChunkIterator = struct { const rows_ptr = self.page.data.rows.ptr(self.page.data.memory); return rows_ptr[self.start..self.end]; } + + /// Returns true if this chunk represents every row in the page. + pub fn fullPage(self: Chunk) bool { + return self.start == 0 and self.end == self.page.data.size.rows; + } }; }; @@ -546,7 +608,12 @@ pub fn rowChunkIterator( // History goes to the top of the active area. This is more expensive // to calculate but also more rare of a thing to iterate over. - .history => .{ .row = self.getTopLeft(.active) }, + .history => history: { + const active_tl = self.getTopLeft(.active); + const history_bot = active_tl.backward(1) orelse + return .{ .row = null }; + break :history .{ .row = history_bot }; + }, }; }; @@ -1187,7 +1254,28 @@ test "PageList rowChunkIterator history two pages" { try testing.expect(chunk.page == s.pages.first.?); const start: usize = 0; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(active_tl.row_offset + 1, chunk.end); + try testing.expectEqual(active_tl.row_offset, chunk.end); } try testing.expect(it.next() == null); } + +test "PageList erase" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Our total rows should be large + try testing.expect(s.totalRows() > s.rows); + + // Erase the entire history, we should be back to just our active set. + s.erase(.{ .history = .{} }, null); + try testing.expectEqual(s.rows, s.totalRows()); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 7979908ba6..d07f2e3ec4 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -4,6 +4,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const ansi = @import("../ansi.zig"); +const kitty = @import("../kitty.zig"); const sgr = @import("../sgr.zig"); const unicode = @import("../../unicode/main.zig"); const PageList = @import("PageList.zig"); @@ -35,6 +36,9 @@ saved_cursor: ?SavedCursor = null, /// protection mode since some sequences such as ECH depend on this. protected_mode: ansi.ProtectedMode = .off, +/// Kitty graphics protocol state. +kitty_images: kitty.graphics.ImageStorage = .{}, + /// The cursor position. pub const Cursor = struct { // The x/y position within the viewport. @@ -113,6 +117,7 @@ pub fn init( } pub fn deinit(self: *Screen) void { + self.kitty_images.deinit(self.alloc); self.pages.deinit(); } @@ -270,6 +275,11 @@ pub const Scroll = union(enum) { /// 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 = {} }), @@ -282,6 +292,11 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { pub fn scrollClear(self: *Screen) !void { try self.pages.scrollClear(); self.cursorAbsolute(0, 0); + + // 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; } // Erase the region specified by tl and bl, inclusive. Erased cells are From d21d7f042672977645be45a74e907673adf1884d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 19:58:21 -0800 Subject: [PATCH 102/428] terminal/new: erase => clear when the data isn't physically erased --- src/terminal/new/Screen.zig | 36 +++++++++++++++++------------------ src/terminal/new/Terminal.zig | 28 +++++++++++++-------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index d07f2e3ec4..d533bf5797 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -299,13 +299,13 @@ pub fn scrollClear(self: *Screen) !void { self.kitty_images.dirty = true; } -// Erase the region specified by tl and bl, inclusive. Erased cells are -// colored with the current style background color. This will erase all +// 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 erased. Otherwise, all cells will be erased. -pub fn eraseRows( +// unprotected cells will be cleared. Otherwise, all cells will be cleared. +pub fn clearRows( self: *Screen, tl: point.Point, bl: ?point.Point, @@ -318,11 +318,11 @@ pub fn eraseRows( const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); const cells = cells_multi[0..self.pages.cols]; - // Erase all cells + // Clear all cells if (protected) { - self.eraseUnprotectedCells(&chunk.page.data, row, cells); + self.clearUnprotectedCells(&chunk.page.data, row, cells); } else { - self.eraseCells(&chunk.page.data, row, cells); + self.clearCells(&chunk.page.data, row, cells); } // Reset our row to point to the proper memory but everything @@ -332,9 +332,9 @@ pub fn eraseRows( } } -/// Erase the cells with the blank cell. This takes care to handle +/// Clear the cells with the blank cell. This takes care to handle /// cleaning up graphemes and styles. -pub fn eraseCells( +pub fn clearCells( self: *Screen, page: *Page, row: *Row, @@ -380,8 +380,8 @@ pub fn eraseCells( @memset(cells, self.blankCell()); } -/// Erase cells but only if they are not protected. -pub fn eraseUnprotectedCells( +/// Clear cells but only if they are not protected. +pub fn clearUnprotectedCells( self: *Screen, page: *Page, row: *Row, @@ -390,7 +390,7 @@ pub fn eraseUnprotectedCells( for (cells) |*cell| { if (cell.protected) continue; const cell_multi: [*]Cell = @ptrCast(cell); - self.eraseCells(page, row, cell_multi[0..1]); + self.clearCells(page, row, cell_multi[0..1]); } } @@ -792,7 +792,7 @@ test "Screen style reset with unset" { try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -test "Screen eraseRows active one line" { +test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator; @@ -800,13 +800,13 @@ test "Screen eraseRows active one line" { defer s.deinit(); try s.testWriteString("hello, world"); - s.eraseRows(.{ .active = .{} }, null, false); + s.clearRows(.{ .active = .{} }, null, false); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); } -test "Screen eraseRows active multi line" { +test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator; @@ -814,13 +814,13 @@ test "Screen eraseRows active multi line" { defer s.deinit(); try s.testWriteString("hello\nworld"); - s.eraseRows(.{ .active = .{} }, null, false); + s.clearRows(.{ .active = .{} }, null, false); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); } -test "Screen eraseRows active styled line" { +test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator; @@ -835,7 +835,7 @@ test "Screen eraseRows active styled line" { const page = s.cursor.page_offset.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - s.eraseRows(.{ .active = .{} }, null, false); + s.clearRows(.{ .active = .{} }, null, false); // We should have none because active cleared it try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 627a3bcb38..2a97432bce 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1192,7 +1192,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.screen.eraseCells(page, row, cells_write); + self.screen.clearCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1286,7 +1286,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { var page = &self.screen.cursor.page_offset.page.data; const cells = page.getCells(row); const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.screen.eraseCells(page, row, cells_write); + self.screen.clearCells(page, row, cells_write); } // Move the cursor to the left margin. But importantly this also @@ -1350,7 +1350,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // it to be empty so we don't split the multi-cell char. const end: *Cell = @ptrCast(x); if (end.wide == .wide) { - self.screen.eraseCells(page, self.screen.cursor.page_row, end[0..1]); + self.screen.clearCells(page, self.screen.cursor.page_row, end[0..1]); } // We work backwards so we don't overwrite data. @@ -1380,7 +1380,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.eraseCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); + self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); } /// Removes amount characters from the current cursor position to the right. @@ -1410,7 +1410,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { // 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.eraseCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); } // Remaining cols from our cursor to the right margin. @@ -1433,7 +1433,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { if (end.wide == .spacer_tail) { const wide: [*]Cell = right + count - 1; assert(wide[0].wide == .wide); - self.screen.eraseCells(page, self.screen.cursor.page_row, wide[0..2]); + self.screen.clearCells(page, self.screen.cursor.page_row, wide[0..2]); } while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { @@ -1462,7 +1462,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.eraseCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); + self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); } pub fn eraseChars(self: *Terminal, count_req: usize) void { @@ -1497,7 +1497,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // 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.eraseCells( + self.screen.clearCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cells[0..end], @@ -1512,7 +1512,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { const cell_multi: [*]Cell = @ptrCast(cells + x); const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; - self.screen.eraseCells( + self.screen.clearCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cell_multi[0..1], @@ -1582,7 +1582,7 @@ pub fn eraseLine( // If we're not respecting protected attributes, we can use a fast-path // to fill the entire line. if (!protected) { - self.screen.eraseCells( + self.screen.clearCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cells[start..end], @@ -1594,7 +1594,7 @@ pub fn eraseLine( const cell_multi: [*]Cell = @ptrCast(cells + x); const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; - self.screen.eraseCells( + self.screen.clearCells( &self.screen.cursor.page_offset.page.data, self.screen.cursor.page_row, cell_multi[0..1], @@ -1667,7 +1667,7 @@ pub fn eraseDisplay( // } // All active area - self.screen.eraseRows( + self.screen.clearRows( .{ .active = .{} }, null, protected, @@ -1687,7 +1687,7 @@ pub fn eraseDisplay( // All lines below if (self.screen.cursor.y + 1 < self.rows) { - self.screen.eraseRows( + self.screen.clearRows( .{ .active = .{ .y = self.screen.cursor.y + 1 } }, null, protected, @@ -1704,7 +1704,7 @@ pub fn eraseDisplay( // All lines above if (self.screen.cursor.y > 0) { - self.screen.eraseRows( + self.screen.clearRows( .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = self.screen.cursor.y - 1 } }, protected, From 5ad6228822451db06a60857f690a926e57708931 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:21:51 -0800 Subject: [PATCH 103/428] terminal/new: eraseDisplay history --- src/terminal/new/PageList.zig | 53 ++++++++++++++++++++-- src/terminal/new/Screen.zig | 85 +++++++++++++++++++++++++++++++++++ src/terminal/new/Terminal.zig | 7 +-- 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 102093beb7..cd1d2e436f 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -364,7 +364,7 @@ fn createPage(self: *PageList) !*List.Node { /// If the top or bottom point is in the middle of a page, the other /// contents in the page will be preserved but the page itself will be /// underutilized (size < capacity). -pub fn erase( +pub fn eraseRows( self: *PageList, tl_pt: point.Point, bl_pt: ?point.Point, @@ -388,7 +388,7 @@ pub fn erase( const rows = chunk.page.data.rows.ptr(chunk.page.data.memory); const scroll_amount = chunk.page.data.size.rows - chunk.end; for (0..scroll_amount) |i| { - const src: *Row = &rows[i + scroll_amount]; + const src: *Row = &rows[i + chunk.end]; const dst: *Row = &rows[i]; const old_dst = dst.*; dst.* = src.*; @@ -400,6 +400,17 @@ pub fn erase( // be written to again (its in the past) or it will grow and the // terminal erase will automatically erase the data. + // If our viewport is on this page and the offset is beyond + // our new end, shift it. + switch (self.viewport) { + .top, .active => {}, + .exact => |*offset| exact: { + if (offset.page != chunk.page) break :exact; + offset.row_offset -|= scroll_amount; + }, + } + + // Our new size is the amount we scrolled chunk.page.data.size.rows = @intCast(scroll_amount); } } @@ -407,6 +418,20 @@ pub fn erase( /// Erase a single page, freeing all its resources. The page can be /// anywhere in the linked list. fn erasePage(self: *PageList, page: *List.Node) void { + // If our viewport is pinned to this page, then we need to update it. + switch (self.viewport) { + .top, .active => {}, + .exact => |*offset| { + if (offset.page == page) { + if (page.next) |next| { + offset.page = next; + } else { + self.viewport = .{ .active = {} }; + } + } + }, + } + // Remove the page from the linked list self.pages.remove(page); @@ -1276,6 +1301,28 @@ test "PageList erase" { try testing.expect(s.totalRows() > s.rows); // Erase the entire history, we should be back to just our active set. - s.erase(.{ .history = .{} }, null); + s.eraseRows(.{ .history = .{} }, null); try testing.expectEqual(s.rows, s.totalRows()); } + +test "PageList erase resets viewport if inside erased page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + try testing.expect(s.viewport.exact.page == s.pages.first.?); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.viewport.exact.page == s.pages.first.?); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index d533bf5797..41035ef45e 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -299,6 +299,23 @@ pub fn scrollClear(self: *Screen) !void { self.kitty_images.dirty = true; } +/// 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.cursorAbsolute(self.cursor.x, self.cursor.y); +} + // 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. @@ -844,3 +861,71 @@ test "Screen clearRows active styled line" { defer alloc.free(str); try testing.expectEqualStrings("", str); } + +test "Terminal: 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 "Terminal: 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); + } +} diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 2a97432bce..0fa5e3003a 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1714,13 +1714,8 @@ pub fn eraseDisplay( // Unsets pending wrap state assert(!self.screen.cursor.pending_wrap); }, - // - // .scrollback => self.screen.clear(.history) catch |err| { - // // This isn't a huge issue, so just log it. - // log.err("failed to clear scrollback: {}", .{err}); - // }, - else => @panic("TODO"), + .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), } } From 5a1d41820bf189d8a06c2951862bb944946531c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:26:04 -0800 Subject: [PATCH 104/428] terminal/new: decaln manages memory --- src/terminal/new/Terminal.zig | 39 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 0fa5e3003a..bc071208a6 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1723,8 +1723,6 @@ pub fn eraseDisplay( /// /// Sets the cursor to the top left corner. pub fn decaln(self: *Terminal) !void { - // TODO: erase display to gc graphemes, styles - // 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; @@ -1751,24 +1749,27 @@ pub fn decaln(self: *Terminal) !void { // 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. - // TODO: cursor across pages - var page = &self.screen.cursor.page_offset.page.data; - const rows: [*]Row = @ptrCast(self.screen.cursor.page_row); - for (0..self.rows) |y| { - const row: *Row = @ptrCast(rows + y); - const cells = page.getCells(row); - @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; + var it = self.screen.pages.rowChunkIterator(.{ .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; + } } } } From f7cba73f5705d9060a1c87a3b2473b2f1f41554d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:35:50 -0800 Subject: [PATCH 105/428] terminal/new: charsets --- src/terminal/Terminal.zig | 4 + src/terminal/new/Screen.zig | 24 +++++- src/terminal/new/Terminal.zig | 144 +++++++++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 12 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d68983ad65..561963568c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2785,6 +2785,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); @@ -2809,6 +2810,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); @@ -2829,6 +2831,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); @@ -2849,6 +2852,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); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 41035ef45e..9c9cf76c36 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -4,6 +4,7 @@ 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"); @@ -29,6 +30,9 @@ cursor: Cursor, /// The saved cursor saved_cursor: ?SavedCursor = 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 @@ -79,8 +83,24 @@ pub const SavedCursor = struct { protected: bool, pending_wrap: bool, origin: bool, - // TODO - //charset: CharsetState, + 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. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index bc071208a6..6db0d1ac17 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -483,8 +483,27 @@ fn printCell( ) void { // TODO: spacers should use a bgcolor only cell - // TODO: charsets - const c: u21 = unmapped_c; + 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; @@ -598,6 +617,31 @@ fn printWrap(self: *Terminal) !void { 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 @@ -787,8 +831,7 @@ pub fn saveCursor(self: *Terminal) void { .protected = self.screen.cursor.protected, .pending_wrap = self.screen.cursor.pending_wrap, .origin = self.modes.get(.origin), - //TODO - //.charset = self.screen.charset, + .charset = self.screen.charset, }; } @@ -804,8 +847,7 @@ pub fn restoreCursor(self: *Terminal) !void { .protected = false, .pending_wrap = false, .origin = false, - // TODO - //.charset = .{}, + .charset = .{}, }; // Set the style first because it can fail @@ -814,7 +856,7 @@ pub fn restoreCursor(self: *Terminal) !void { errdefer self.screen.cursor.style = old_style; try self.screen.manualStyleUpdate(); - //self.screen.charset = saved.charset; + 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; @@ -2429,6 +2471,88 @@ test "Terminal: print writes to bottom if scrolled" { } } +test "Terminal: print charset" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + + // Basic grid writing + try t.print('`'); + t.configureCharset(.G0, .utf8); + try t.print('`'); + t.configureCharset(.G0, .ascii); + try t.print('`'); + t.configureCharset(.G0, .dec_special); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("```◆", str); + } +} + +test "Terminal: print charset outside of ASCII" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + + // Basic grid writing + t.configureCharset(.G0, .dec_special); + try t.print('`'); + try t.print(0x1F600); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆ ", str); + } +} + +test "Terminal: print invoke charset" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + t.configureCharset(.G1, .dec_special); + + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, false); + try t.print('`'); + try t.print('`'); + t.invokeCharset(.GL, .G0, false); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`◆◆`", str); + } +} + +test "Terminal: print invoke charset single" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + t.configureCharset(.G1, .dec_special); + + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, true); + try t.print('`'); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`◆`", str); + } +} + test "Terminal: soft wrap" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); @@ -5946,15 +6070,15 @@ test "Terminal: saveCursor" { defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); - //t.screen.charset.gr = .G3; + t.screen.charset.gr = .G3; t.modes.set(.origin, true); t.saveCursor(); - //t.screen.charset.gr = .G0; + t.screen.charset.gr = .G0; try t.setAttribute(.{ .unset = {} }); t.modes.set(.origin, false); try t.restoreCursor(); try testing.expect(t.screen.cursor.style.flags.bold); - //try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } From 7b263ef415938320d50afdcbfc8b7c1e98d8218f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:48:02 -0800 Subject: [PATCH 106/428] terminal/new: semantic prompts --- src/terminal/Terminal.zig | 2 + src/terminal/new/Terminal.zig | 125 +++++++++++++++++++++++++++++----- src/terminal/new/page.zig | 29 +++++++- 3 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 561963568c..17f2b6af94 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2641,6 +2641,7 @@ test "Terminal: soft wrap" { } } +// X test "Terminal: soft wrap with semantic prompt" { var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); @@ -4907,6 +4908,7 @@ test "Terminal: insert mode pushing off wide character" { } } +// X test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; var t = try init(alloc, 3, 2); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 6db0d1ac17..0771fd5f48 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -39,18 +39,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. @@ -603,17 +591,14 @@ fn printWrap(self: *Terminal) !void { // 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. - // TODO(mitchellh): before merge - //const old_prompt = row.getSemanticPrompt(); + 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); - // TODO(mitchellh): before merge // 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); + self.screen.cursor.page_row.semantic_prompt = old_prompt; self.screen.cursor.page_row.wrap_continuation = true; } @@ -889,6 +874,65 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { } } +/// 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 { @@ -2568,6 +2612,23 @@ test "Terminal: soft wrap" { } } +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); @@ -7160,3 +7221,33 @@ test "Terminal: eraseDisplay protected above" { 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()); +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index ca89fed625..66975416ee 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -503,7 +503,34 @@ pub const Row = packed struct(u64) { /// At the time of writing this, the speed difference is around 4x. styled: bool = false, - _padding: u28 = 0, + /// The semantic prompt type for this row as specified by the + /// running program, or "unknown" if it was never set. + semantic_prompt: SemanticPrompt = .unknown, + + _padding: u25 = 0, + + /// 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; + } + }; }; /// A cell represents a single terminal grid cell. From 8283905ad99ab4b146f9f0d4f7b1c170d75f1d3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:55:29 -0800 Subject: [PATCH 107/428] terminal/new: primary/alt screen --- src/terminal/Terminal.zig | 2 + src/terminal/new/Screen.zig | 4 + src/terminal/new/Terminal.zig | 138 ++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 17f2b6af94..d882078921 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -4939,6 +4939,7 @@ test "Terminal: cursorIsAtPrompt" { try testing.expect(t.cursorIsAtPrompt()); } +// X test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; var t = try init(alloc, 3, 2); @@ -5491,6 +5492,7 @@ test "Terminal: saveCursor" { 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); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 9c9cf76c36..02f3983a4c 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -8,6 +8,7 @@ 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"); @@ -30,6 +31,9 @@ cursor: Cursor, /// The saved cursor saved_cursor: ?SavedCursor = null, +/// The selection for this screen (if any). +selection: ?Selection = null, + /// The charset state charset: CharsetState = .{}, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 0771fd5f48..5aeabd7c1e 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1950,6 +1950,98 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { return stream.getWritten(); } +/// 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. +/// +/// 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, + 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}); + }; +} + +/// 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. /// @@ -6143,6 +6235,36 @@ test "Terminal: saveCursor" { try testing.expect(t.modes.get(.origin)); } +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.x = 2; + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + 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.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; + try t.setAttribute(.{ .reset_bold = {} }); + t.modes.set(.origin, false); + t.primaryScreen(.{ + .cursor_save = true, + .clear_on_enter = true, + }); + try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); +} + test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -7251,3 +7373,19 @@ test "Terminal: cursorIsAtPrompt" { 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()); +} From 1121002f68769a8946203e845807d2db87819d10 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 Feb 2024 21:59:48 -0800 Subject: [PATCH 108/428] terminal/new: fullreset --- src/terminal/Terminal.zig | 3 ++ src/terminal/new/Screen.zig | 3 ++ src/terminal/new/Terminal.zig | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d882078921..2d44bd0a0d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2196,6 +2196,7 @@ pub fn fullReset(self: *Terminal, alloc: Allocator) void { 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); @@ -2209,6 +2210,7 @@ test "Terminal: fullReset with a non-empty pen" { 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); @@ -2223,6 +2225,7 @@ test "Terminal: fullReset origin mode" { 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); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 02f3983a4c..bd2b9d4d16 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -44,6 +44,9 @@ charset: CharsetState = .{}, /// 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 = .{}, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 5aeabd7c1e..306fd2762f 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -2050,6 +2050,31 @@ 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 }); + self.screen.charset = .{}; + self.modes = .{}; + self.flags = .{}; + self.tabstops.reset(TABSTOP_INTERVAL); + self.screen.saved_cursor = null; + self.screen.selection = null; + self.screen.kitty_keyboard = .{}; + self.screen.protected_mode = .off; + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + self.previous_char = null; + self.eraseDisplay(.scrollback, false); + self.eraseDisplay(.complete, false); + self.screen.cursorAbsolute(0, 0); + self.pwd.clearRetainingCapacity(); + self.status_display = .main; +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, 40, 40); @@ -7389,3 +7414,44 @@ test "Terminal: cursorIsAtPrompt alternate screen" { 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); +} From e94d0f26a7e427dc955814e84731b7079d39ad43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 08:59:21 -0800 Subject: [PATCH 109/428] terminal/new: properly handle zero scrollback configs --- src/terminal/new/PageList.zig | 31 ++++++++ src/terminal/new/Screen.zig | 130 +++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index cd1d2e436f..90a77df425 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -14,6 +14,8 @@ const OffsetBuf = size.OffsetBuf; const Page = pagepkg.Page; const Row = pagepkg.Row; +const log = std.log.scoped(.page_list); + /// The number of PageList.Nodes we preheat the pool with. A node is /// a very small struct so we can afford to preheat many, but the exact /// number is uncertain. Any number too large is wasting memory, any number @@ -369,6 +371,9 @@ pub fn eraseRows( tl_pt: point.Point, bl_pt: ?point.Point, ) void { + // The count of rows that was erased. + var erased: usize = 0; + // A rowChunkIterator iterates one page at a time from the back forward. // "back" here is in terms of scrollback, but actually the front of the // linked list. @@ -378,6 +383,7 @@ pub fn eraseRows( // the linked list. if (chunk.fullPage()) { self.erasePage(chunk.page); + erased += chunk.page.data.size.rows; continue; } @@ -412,6 +418,20 @@ pub fn eraseRows( // Our new size is the amount we scrolled chunk.page.data.size.rows = @intCast(scroll_amount); + erased += chunk.end; + } + + // If we deleted active, we need to regrow because one of our invariants + // is that we always have full active space. + if (tl_pt == .active) { + for (0..erased) |_| _ = self.grow() catch |err| { + // If this fails its a pretty big issue actually... but I don't + // want to turn this function into an error-returning function + // because erasing active is so rare and even if it happens failing + // is even more rare... + log.err("failed to regrow active area after erase err={}", .{err}); + return; + }; } } @@ -1326,3 +1346,14 @@ test "PageList erase resets viewport if inside erased page" { s.eraseRows(.{ .history = .{} }, null); try testing.expect(s.viewport.exact.page == s.pages.first.?); } + +test "PageList erase active regrows automatically" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expect(s.totalRows() == s.rows); + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 10 } }); + try testing.expect(s.totalRows() == s.rows); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index bd2b9d4d16..a97e5b3487 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -25,6 +25,11 @@ 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, @@ -111,6 +116,12 @@ pub const CharsetState = struct { }; /// 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, @@ -133,6 +144,7 @@ pub fn init( return .{ .alloc = alloc, .pages = pages, + .no_scrollback = max_scrollback == 0, .cursor = .{ .x = 0, .y = 0, @@ -255,19 +267,54 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) 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 { + const get = self.pages.getCell(.{ .active = .{ + .x = self.cursor.x, + .y = self.cursor.y, + } }).?; + self.cursor.page_offset = .{ .page = get.page, .row_offset = get.row_idx }; + self.cursor.page_row = get.row; + self.cursor.page_cell = get.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); - // Grow our pages by one row. The PageList will handle if we need to - // allocate, prune scrollback, whatever. - _ = try self.pages.grow(); - const page_offset = self.cursor.page_offset.forward(1).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + // 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 reload our cursor because the pointers are now invalid. + const page_offset = self.cursor.page_offset; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + 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_offset.page.data, + self.cursor.page_row, + page_offset.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_offset = self.cursor.page_offset.forward(1).?; + const page_rac = page_offset.rowAndCell(self.cursor.x); + self.cursor.page_offset = page_offset; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } // The newly created line needs to be styled according to the bg color // if it is set. @@ -340,7 +387,7 @@ pub fn eraseRows( // 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.cursorAbsolute(self.cursor.x, self.cursor.y); + self.cursorReload(); } // Clear the region specified by tl and bl, inclusive. Cleared cells are @@ -774,6 +821,67 @@ test "Screen read and write newline" { 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" { + 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; @@ -889,7 +997,7 @@ test "Screen clearRows active styled line" { try testing.expectEqualStrings("", str); } -test "Terminal: eraseRows history" { +test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator; @@ -923,7 +1031,7 @@ test "Terminal: eraseRows history" { } } -test "Terminal: eraseRows history with more lines" { +test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; From bfa574fa60f8a3a3c42d41af78a3181179aa3c84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 09:10:56 -0800 Subject: [PATCH 110/428] terminal/new: Screen new scrolldown should inherit bg color --- src/terminal/Screen.zig | 1 + src/terminal/new/Screen.zig | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index dcef37328d..53471ac4a8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3801,6 +3801,7 @@ test "Screen: getLine soft wrap" { try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null); } +// X test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index a97e5b3487..8eb2021897 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -314,6 +314,16 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_offset = page_offset; 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_offset.page.data, + self.cursor.page_row, + page_offset.page.data.getCells(self.cursor.page_row), + ); + } } // The newly created line needs to be styled according to the bg color @@ -1064,3 +1074,42 @@ test "Screen eraseRows history with more lines" { 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(); + { + // Test our contents rotated + 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 = {} }); + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} From 7ce4010f7a37b2a8aa4f8f62858f741b3e3f15ae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 09:19:27 -0800 Subject: [PATCH 111/428] terminal/new: scrolling viewport into active area pins to active --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 84 +++++++++++++++++++---------------- src/terminal/new/Screen.zig | 19 ++++++++ 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 53471ac4a8..0e00ec2fc1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3839,6 +3839,7 @@ test "Screen: scrolling" { } } +// X test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 90a77df425..c188c1a0cd 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -203,53 +203,42 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { .active => self.viewport = .{ .active = {} }, .top => self.viewport = .{ .top = {} }, - .delta_row => |n| { + .delta_row => |n| delta_row: { if (n == 0) return; const top = self.getTopLeft(.viewport); const offset: RowOffset = if (n < 0) switch (top.backwardOverflow(@intCast(-n))) { .offset => |v| v, .overflow => |v| v.end, - } else forward: { - // Not super happy with the logic to scroll forward. I think - // this is pretty slow, but it is human-driven (scrolling - // this way) so hyper speed isn't strictly necessary. Still, - // it feels bad. - - const forward_offset = switch (top.forwardOverflow(@intCast(n))) { - .offset => |v| v, - .overflow => |v| v.end, - }; - - var final_offset: ?RowOffset = forward_offset; - - // Ensure we have at least rows rows in the viewport. There - // is probably a smarter way to do this. - var page = self.pages.last.?; - var rem = self.rows; - while (rem > page.data.size.rows) { - rem -= page.data.size.rows; - - // If we see our forward page here then we know its - // beyond the active area and we can set final null. - if (page == forward_offset.page) final_offset = null; + } else switch (top.forwardOverflow(@intCast(n))) { + .offset => |v| v, + .overflow => |v| v.end, + }; - page = page.prev.?; // assertion: we always have enough rows for active + // If we are still within the active area, then we pin the + // viewport to active. This isn't EXACTLY the same behavior as + // other scrolling because normally when you scroll the viewport + // is pinned to _that row_ even if new scrollback is created. + // But in a terminal when you get to the bottom and back into the + // active area, you usually expect that the viewport will now + // follow the active area. + const active = self.getTopLeft(.active); + if (offset.page == active.page) { + if (offset.row_offset >= active.row_offset) { + self.viewport = .{ .active = {} }; + break :delta_row; } - const active_offset = .{ .page = page, .row_offset = page.data.size.rows - rem }; - - // If we have a final still and we're on the same page - // but the active area is before the forward area, then - // we can use the active area. - if (final_offset != null and - active_offset.page == forward_offset.page and - forward_offset.row_offset > active_offset.row_offset) - { - final_offset = active_offset; + } else active: { + // Check forward pages too. + var page = active.page.next orelse break :active; + while (true) { + if (page == offset.page) { + self.viewport = .{ .active = {} }; + break :delta_row; + } + page = page.next orelse break :active; } - - break :forward final_offset orelse active_offset; - }; + } self.viewport = .{ .exact = offset }; }, @@ -1100,6 +1089,25 @@ test "PageList scroll delta row forward into active" { } } +test "PageList scroll delta row back without space preserves active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + s.scroll(.{ .delta_row = -1 }); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expect(s.viewport == .active); +} + test "PageList scroll clear" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 8eb2021897..434971bc1b 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1113,3 +1113,22 @@ test "Screen: scrolling" { 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); + } +} From 3842ca9212cd11e96a6d6b85b168b84a0fabf6cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 09:35:56 -0800 Subject: [PATCH 112/428] terminal/new: screen scrolling tests --- src/terminal/Screen.zig | 4 + src/terminal/new/PageList.zig | 54 ++++++----- src/terminal/new/Screen.zig | 163 +++++++++++++++++++++++++++++++++- src/terminal/new/Terminal.zig | 2 +- 4 files changed, 194 insertions(+), 29 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0e00ec2fc1..2650098f18 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3860,6 +3860,7 @@ test "Screen: scroll down from 0" { } } +// X test "Screen: scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -3958,6 +3959,7 @@ test "Screen: scrollback" { } } +// X test "Screen: scrollback with large delta" { const testing = std.testing; const alloc = testing.allocator; @@ -3987,6 +3989,7 @@ test "Screen: scrollback with large delta" { } } +// X test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; @@ -4004,6 +4007,7 @@ test "Screen: scrollback empty" { } } +// X test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index c188c1a0cd..9adab8e5b0 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -363,10 +363,10 @@ pub fn eraseRows( // The count of rows that was erased. var erased: usize = 0; - // A rowChunkIterator iterates one page at a time from the back forward. + // A pageIterator iterates one page at a time from the back forward. // "back" here is in terms of scrollback, but actually the front of the // linked list. - var it = self.rowChunkIterator(tl_pt, bl_pt); + var it = self.pageIterator(tl_pt, bl_pt); while (it.next()) |chunk| { // If the chunk is a full page, deinit thit page and remove it from // the linked list. @@ -484,15 +484,21 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { } pub const RowIterator = struct { - row: ?RowOffset = null, - limit: ?usize = null, + page_it: PageIterator, + chunk: ?PageIterator.Chunk = null, + offset: usize = 0, pub fn next(self: *RowIterator) ?RowOffset { - const row = self.row orelse return null; - self.row = row.forward(1); - if (self.limit) |*limit| { - limit.* -= 1; - if (limit.* == 0) self.row = null; + const chunk = self.chunk orelse return null; + const row: RowOffset = .{ .page = chunk.page, .row_offset = self.offset }; + + // Increase our offset in the chunk + self.offset += 1; + + // If we are beyond the chunk end, we need to move to the next chunk. + if (self.offset >= chunk.end) { + self.chunk = self.page_it.next(); + if (self.chunk) |c| self.offset = c.start; } return row; @@ -507,14 +513,14 @@ pub const RowIterator = struct { pub fn rowIterator( self: *const PageList, tl_pt: point.Point, + bl_pt: ?point.Point, ) RowIterator { - const tl = self.getTopLeft(tl_pt); - - // TODO: limits - return .{ .row = tl.forward(tl_pt.coord().y) }; + var page_it = self.pageIterator(tl_pt, bl_pt); + const chunk = page_it.next() orelse return .{ .page_it = page_it }; + return .{ .page_it = page_it, .chunk = chunk, .offset = chunk.start }; } -pub const RowChunkIterator = struct { +pub const PageIterator = struct { row: ?RowOffset = null, limit: Limit = .none, @@ -524,7 +530,7 @@ pub const RowChunkIterator = struct { row: RowOffset, }; - pub fn next(self: *RowChunkIterator) ?Chunk { + pub fn next(self: *PageIterator) ?Chunk { // Get our current row location const row = self.row orelse return null; @@ -619,15 +625,15 @@ pub const RowChunkIterator = struct { /// (inclusive). If bl_pt is null, the entire region specified by the point /// tag will be iterated over. tl_pt and bl_pt must be the same tag, and /// bl_pt must be greater than or equal to tl_pt. -pub fn rowChunkIterator( +pub fn pageIterator( self: *const PageList, tl_pt: point.Point, bl_pt: ?point.Point, -) RowChunkIterator { +) PageIterator { // TODO: bl_pt assertions const tl = self.getTopLeft(tl_pt); - const limit: RowChunkIterator.Limit = limit: { + const limit: PageIterator.Limit = limit: { if (bl_pt) |pt| { const bl = self.getTopLeft(pt); break :limit .{ .row = bl.forward(pt.coord().y).? }; @@ -1227,7 +1233,7 @@ test "PageList grow prune scrollback" { try testing.expectEqual(page1_node, s.pages.last.?); } -test "PageList rowChunkIterator single page" { +test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; @@ -1238,7 +1244,7 @@ test "PageList rowChunkIterator single page" { try testing.expect(s.pages.first.?.next == null); // Iterate the active area - var it = s.rowChunkIterator(.{ .active = .{} }, null); + var it = s.pageIterator(.{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -1250,7 +1256,7 @@ test "PageList rowChunkIterator single page" { try testing.expect(it.next() == null); } -test "PageList rowChunkIterator two pages" { +test "PageList pageIterator two pages" { const testing = std.testing; const alloc = testing.allocator; @@ -1266,7 +1272,7 @@ test "PageList rowChunkIterator two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.rowChunkIterator(.{ .active = .{} }, null); + var it = s.pageIterator(.{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -1284,7 +1290,7 @@ test "PageList rowChunkIterator two pages" { try testing.expect(it.next() == null); } -test "PageList rowChunkIterator history two pages" { +test "PageList pageIterator history two pages" { const testing = std.testing; const alloc = testing.allocator; @@ -1300,7 +1306,7 @@ test "PageList rowChunkIterator history two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.rowChunkIterator(.{ .history = .{} }, null); + var it = s.pageIterator(.{ .history = .{} }, null); { const active_tl = s.getTopLeft(.active); const chunk = it.next().?; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 434971bc1b..293901cf08 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -412,7 +412,7 @@ pub fn clearRows( bl: ?point.Point, protected: bool, ) void { - var it = self.pages.rowChunkIterator(tl, bl); + var it = self.pages.pageIterator(tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_offset = row.cells; @@ -682,7 +682,7 @@ pub fn dumpString( ) !void { var blank_rows: usize = 0; - var iter = self.pages.rowIterator(tl); + var iter = self.pages.rowIterator(tl, null); while (iter.next()) |row_offset| { const rac = row_offset.rowAndCell(0); const cells = cells: { @@ -1087,7 +1087,6 @@ test "Screen: scrolling" { // Scroll down, should still be bottom try s.cursorDownScroll(); { - // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); @@ -1107,7 +1106,6 @@ test "Screen: scrolling" { s.scroll(.{ .active = {} }); { - // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); @@ -1132,3 +1130,160 @@ test "Screen: scroll down from 0" { 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); + } +} diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 306fd2762f..ab9fd1a208 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1839,7 +1839,7 @@ pub fn decaln(self: *Terminal) !void { self.eraseDisplay(.complete, false); // Fill with Es, does not move cursor. - var it = self.screen.pages.rowChunkIterator(.{ .active = .{} }, null); + var it = self.screen.pages.pageIterator(.{ .active = .{} }, null); while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); From 26edb51d0c5e9a3979b65cb94cedd3c66eb4bc36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 09:46:14 -0800 Subject: [PATCH 113/428] terminal/new: screen scrollClear tests --- src/terminal/Screen.zig | 4 ++ src/terminal/new/Screen.zig | 118 +++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2650098f18..d531625ebf 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4174,6 +4174,7 @@ test "Screen: scrolling with scrollback available doesn't move selection" { } } +// X test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; @@ -4201,6 +4202,7 @@ test "Screen: scroll and clear full screen" { } } +// X test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; @@ -4228,6 +4230,7 @@ test "Screen: scroll and clear partial screen" { } } +// X test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; @@ -4238,6 +4241,7 @@ test "Screen: scroll and clear empty screen" { 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; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 293901cf08..6232607fce 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -375,7 +375,7 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { /// to be on top. pub fn scrollClear(self: *Screen) !void { try self.pages.scrollClear(); - self.cursorAbsolute(0, 0); + 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 @@ -1287,3 +1287,119 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { 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); + } +} From 5fe495e228435d0042304e1d550aac9e5731832c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 09:51:11 -0800 Subject: [PATCH 114/428] terminal: noting uncopied tests --- src/terminal/Screen.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d531625ebf..3143eb2151 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4281,6 +4281,7 @@ test "Screen: scroll and clear ignore blank lines" { } } +// X - i don't think we need rowIterator test "Screen: history region with no scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -4305,6 +4306,7 @@ test "Screen: history region with no scrollback" { try testing.expect(count == 0); } +// X - duplicated test above test "Screen: history region with scrollback" { const testing = std.testing; const alloc = testing.allocator; From daf113b1475f612ecc50da025c5222d54ee9fa61 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 11:30:28 -0800 Subject: [PATCH 115/428] terminal/new: page clone, screen/pagelist clone wip --- src/terminal/new/PageList.zig | 41 ++++++++++++++++ src/terminal/new/Screen.zig | 28 +++++++++++ src/terminal/new/page.zig | 91 ++++++++++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 9adab8e5b0..1e8004b514 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -179,6 +179,47 @@ pub fn deinit(self: *PageList) void { self.pool.deinit(); } +/// Clone this pagelist from the top to bottom (inclusive). +pub fn clone( + self: *const PageList, + alloc: Allocator, + top: point.Point, + bot: ?point.Point, +) !PageList { + var it = self.pageIterator(top, bot); + + // First, count our pages so our preheat is exactly what we need. + const page_count: usize = page_count: { + // Copy the iterator so we don't mutate our original. + var count_it = it; + var count: usize = 0; + while (count_it.next()) |_| count += 1; + break :page_count count; + }; + + // Setup our pools + var pool = try Pool.initPreheated(alloc, page_count); + errdefer pool.deinit(); + var page_pool = try PagePool.initPreheated(std.heap.page_allocator, page_count); + errdefer page_pool.deinit(); + + // Copy our pages + const page_list: List = .{}; + while (it.next()) |chunk| { + _ = chunk; + } + + return .{ + .alloc = alloc, + .pool = pool, + .page_pool = page_pool, + .pages = page_list, + .max_size = self.max_size, + .cols = self.cols, + .rows = self.rows, + }; +} + /// Scroll options. pub const Scroll = union(enum) { /// Scroll to the active area. This is also sometimes referred to as diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 6232607fce..0430c1c00b 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -160,6 +160,34 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } +/// Clone the screen. +/// +/// This will copy: +/// +/// - Screen dimensions +/// - Screen data (cell state, etc.) for the region +/// - Cursor if its in the region. If the cursor is not in the region +/// then it will be placed at the top-left of the new screen. +/// +/// 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, + bottom: ?point.Point, +) !Screen { + _ = self; + _ = alloc; + _ = top; + _ = bottom; +} + 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); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 66975416ee..60346c1af6 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const testing = std.testing; +const fastmem = @import("../../fastmem.zig"); const color = @import("../color.zig"); const sgr = @import("../sgr.zig"); const style = @import("style.zig"); @@ -168,6 +169,42 @@ pub const Page = struct { self.* = undefined; } + /// Clone the contents of this page. This will allocate new memory + /// using the page allocator. If you want to manage memory manually, + /// use cloneBuf. + pub fn clone(self: *const Page) !Page { + const backing = try std.os.mmap( + null, + self.memory.len, + std.os.PROT.READ | std.os.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer std.os.munmap(backing); + return self.cloneBuf(backing); + } + + /// Clone the entire contents of this page. + /// + /// The buffer must be at least the size of self.memory. + pub fn cloneBuf(self: *const Page, buf: []align(std.mem.page_size) u8) Page { + assert(buf.len >= self.memory.len); + + // The entire concept behind a page is that everything is stored + // as offsets so we can do a simple linear copy of the backing + // memory and copy all the offsets and everything will work. + var result = self.*; + result.memory = buf[0..self.memory.len]; + + // This is a memcpy. We may want to investigate if there are + // faster ways to do this (i.e. copy-on-write tricks) but I suspect + // they'll be slower. I haven't experimented though. + fastmem.copy(u8, result.memory, self.memory); + + return result; + } + /// Get a single row. y must be valid. pub fn getRow(self: *const Page, y: usize) *Row { assert(y < self.size.rows); @@ -222,7 +259,7 @@ pub const Page = struct { break :grapheme false; }; if (!src_grapheme) { - @memcpy(dst_cells, src_cells); + fastmem.copy(Cell, dst_cells, src_cells); return; } @@ -276,7 +313,7 @@ pub const Page = struct { const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); errdefer self.grapheme_alloc.free(self.memory, cps); const old_cps = slice.offset.ptr(self.memory)[0..slice.len]; - @memcpy(cps[0..old_cps.len], old_cps); + fastmem.copy(u21, cps[0..old_cps.len], old_cps); cps[slice.len] = cp; slice.* = .{ .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), @@ -819,3 +856,53 @@ test "Page clearGrapheme not all cells" { try testing.expect(!rac.cell.hasGrapheme()); try testing.expect(rac2.cell.hasGrapheme()); } + +test "Page clone" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try page.clone(); + defer page2.deinit(); + try testing.expectEqual(page2.capacity, page.capacity); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} From bda44f9b0cc2a561e6888413ddbf833abd49da55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 21:35:01 -0800 Subject: [PATCH 116/428] terminal/new: Screen.clone --- src/terminal/new/PageList.zig | 151 +++++++++++++++++++++++++++++++++- src/terminal/new/Screen.zig | 92 +++++++++++++++++++-- src/terminal/new/page.zig | 37 +++++++++ 3 files changed, 270 insertions(+), 10 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 1e8004b514..9de7fa889f 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -180,6 +180,12 @@ pub fn deinit(self: *PageList) void { } /// Clone this pagelist from the top to bottom (inclusive). +/// +/// The viewport is always moved to the top-left. +/// +/// The cloned pagelist must contain at least enough rows for the active +/// area. If the region specified has less rows than the active area then +/// rows will be added to the bottom of the region to make up the difference. pub fn clone( self: *const PageList, alloc: Allocator, @@ -204,20 +210,75 @@ pub fn clone( errdefer page_pool.deinit(); // Copy our pages - const page_list: List = .{}; + var page_list: List = .{}; + var total_rows: usize = 0; while (it.next()) |chunk| { - _ = chunk; + // Clone the page + const page = try pool.create(); + const page_buf = try page_pool.create(); + page.* = .{ .data = chunk.page.data.cloneBuf(page_buf) }; + page_list.append(page); + + // If this is a full page then we're done. + if (chunk.fullPage()) { + total_rows += page.data.size.rows; + continue; + } + + // If this is just a shortened chunk off the end we can just + // shorten the size. We don't worry about clearing memory here because + // as the page grows the memory will be reclaimable because the data + // is still valid. + if (chunk.start == 0) { + page.data.size.rows = @intCast(chunk.end); + total_rows += chunk.end; + continue; + } + + // Kind of slow, we want to shift the rows up in the page up to + // end and then resize down. + const rows = page.data.rows.ptr(page.data.memory); + const len = chunk.end - chunk.start; + for (0..len) |i| { + const src: *Row = &rows[i + chunk.start]; + const dst: *Row = &rows[i]; + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + page.data.size.rows = @intCast(len); + total_rows += len; } - return .{ + var result: PageList = .{ .alloc = alloc, .pool = pool, .page_pool = page_pool, .pages = page_list, + .page_size = PagePool.item_size * page_count, .max_size = self.max_size, .cols = self.cols, .rows = self.rows, + .viewport = .{ .top = {} }, }; + + // We always need to have enough rows for our viewport because this is + // a pagelist invariant that other code relies on. + if (total_rows < self.rows) { + const len = self.rows - total_rows; + for (0..len) |_| { + _ = try result.grow(); + + // Clear the row. This is not very fast but in reality right + // now we rarely clone less than the active area and if we do + // the area is by definition very small. + const last = result.pages.last.?; + const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; + last.data.clearCells(row, 0, result.cols); + } + } + + return result; } /// Scroll options. @@ -1412,3 +1473,87 @@ test "PageList erase active regrows automatically" { s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 10 } }); try testing.expect(s.totalRows() == s.rows); } + +test "PageList clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + var s2 = try s.clone(alloc, .{ .screen = .{} }, null); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); +} + +test "PageList clone partial trimmed right" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone( + alloc, + .{ .screen = .{} }, + .{ .screen = .{ .y = 39 } }, + ); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 40), s2.totalRows()); +} + +test "PageList clone partial trimmed left" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone( + alloc, + .{ .screen = .{ .y = 10 } }, + null, + ); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 40), s2.totalRows()); +} + +test "PageList clone partial trimmed both" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 20, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + try s.growRows(30); + + var s2 = try s.clone( + alloc, + .{ .screen = .{ .y = 10 } }, + .{ .screen = .{ .y = 35 } }, + ); + defer s2.deinit(); + try testing.expectEqual(@as(usize, 26), s2.totalRows()); +} + +test "PageList clone less than active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 5 } }, + null, + ); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 0430c1c00b..30021baf86 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -166,8 +166,20 @@ pub fn deinit(self: *Screen) void { /// /// - Screen dimensions /// - Screen data (cell state, etc.) for the region -/// - Cursor if its in the region. If the cursor is not in the region -/// then it will be placed at the top-left of the new screen. +/// +/// 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: /// @@ -180,12 +192,19 @@ pub fn clone( self: *const Screen, alloc: Allocator, top: point.Point, - bottom: ?point.Point, + bot: ?point.Point, ) !Screen { - _ = self; - _ = alloc; - _ = top; - _ = bottom; + var pages = 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 { @@ -1431,3 +1450,62 @@ test "Screen: scroll and clear ignore blank lines" { 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); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 60346c1af6..5673e2c903 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -266,6 +266,43 @@ pub const Page = struct { @panic("TODO: grapheme move"); } + /// Clear the cells in the given row. This will reclaim memory used + /// by graphemes and styles. Note that if the style cleared is still + /// active, Page cannot know this and it will still be ref counted down. + /// The best solution for this is to artificially increment the ref count + /// prior to calling this function. + pub fn clearCells( + self: *Page, + row: *Row, + left: usize, + end: usize, + ) void { + const cells = row.cells.ptr(self.memory)[left..end]; + if (row.grapheme) { + for (cells) |*cell| { + if (cell.hasGrapheme()) self.clearGrapheme(row, cell); + } + } + + if (row.styled) { + for (cells) |*cell| { + if (cell.style_id == style.default_id) continue; + + if (self.styles.lookupId(self.memory, cell.style_id)) |prev_style| { + // Below upsert can't fail because it should already be present + const md = self.styles.upsert(self.memory, prev_style.*) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) self.styles.remove(self.memory, cell.style_id); + } + } + + if (cells.len == self.size.cols) row.styled = false; + } + + @memset(cells, .{}); + } + /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) !void { if (comptime std.debug.runtime_safety) assert(cell.hasText()); From 2725b7d9b2572185e6a45077087c1b6b1fe2dff6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 21:49:11 -0800 Subject: [PATCH 117/428] bench/screen-copy --- src/bench/screen-copy.sh | 12 +++++ src/bench/screen-copy.zig | 106 ++++++++++++++++++++++++++++++++++++++ src/build_config.zig | 1 + src/main.zig | 1 + 4 files changed, 120 insertions(+) create mode 100755 src/bench/screen-copy.sh create mode 100644 src/bench/screen-copy.zig diff --git a/src/bench/screen-copy.sh b/src/bench/screen-copy.sh new file mode 100755 index 0000000000..7b4a3d1d20 --- /dev/null +++ b/src/bench/screen-copy.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +hyperfine \ + --warmup 10 \ + -n new \ + "./zig-out/bin/bench-screen-copy --mode=new${ARGS}" \ + -n old \ + "./zig-out/bin/bench-screen-copy --mode=old${ARGS}" + diff --git a/src/bench/screen-copy.zig b/src/bench/screen-copy.zig new file mode 100644 index 0000000000..b55331d467 --- /dev/null +++ b/src/bench/screen-copy.zig @@ -0,0 +1,106 @@ +//! This benchmark tests the speed of copying the active area of the screen. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli = @import("../cli.zig"); +const terminal = @import("../terminal/main.zig"); + +const Args = struct { + mode: Mode = .old, + + /// The number of times to loop. + count: usize = 5_000, + + /// Rows and cols in the terminal. + rows: usize = 100, + cols: usize = 300, + + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + pub fn deinit(self: *Args) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +const Mode = enum { + /// The default allocation strategy of the structure. + old, + + /// Use a memory pool to allocate pages from a backing buffer. + new, +}; + +pub const std_options: std.Options = .{ + .log_level = .debug, +}; + +pub fn main() !void { + // We want to use the c allocator because it is much faster than GPA. + const alloc = std.heap.c_allocator; + + // Parse our args + var args: Args = .{}; + defer args.deinit(); + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try cli.args.parse(Args, alloc, &args, &iter); + } + + // Handle the modes that do not depend on terminal state first. + switch (args.mode) { + .old => { + var t = try terminal.Terminal.init(alloc, args.cols, args.rows); + defer t.deinit(alloc); + try benchOld(alloc, &t, args); + }, + + .new => { + var t = try terminal.new.Terminal.init( + alloc, + @intCast(args.cols), + @intCast(args.rows), + ); + defer t.deinit(alloc); + try benchNew(alloc, &t, args); + }, + } +} + +noinline fn benchOld(alloc: Allocator, t: *terminal.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |_| { + var s = try t.screen.clone( + alloc, + .{ .active = 0 }, + .{ .active = t.rows - 1 }, + ); + errdefer s.deinit(); + } +} + +noinline fn benchNew(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |_| { + var s = try t.screen.clone(alloc, .{ .active = .{} }, null); + errdefer s.deinit(); + } +} diff --git a/src/build_config.zig b/src/build_config.zig index d724cd77fa..8992af8ad9 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -144,5 +144,6 @@ pub const ExeEntrypoint = enum { bench_codepoint_width, bench_grapheme_break, bench_page_init, + bench_screen_copy, bench_vt_insert_lines, }; diff --git a/src/main.zig b/src/main.zig index c66c8e2267..5d14e927b7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,5 +11,6 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), .bench_page_init => @import("bench/page-init.zig"), + .bench_screen_copy => @import("bench/screen-copy.zig"), .bench_vt_insert_lines => @import("bench/vt-insert-lines.zig"), }; From 0c888af4709eacf99bd67c002316968db616abc2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 28 Feb 2024 21:57:07 -0800 Subject: [PATCH 118/428] cli: arg parsing supports more int types --- src/cli/args.zig | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/cli/args.zig b/src/cli/args.zig index 0363ba9be3..49c5152ac4 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -234,20 +234,18 @@ fn parseIntoField( bool => try parseBool(value orelse "t"), - u8 => std.fmt.parseInt( - u8, - value orelse return error.ValueRequired, - 0, - ) catch return error.InvalidValue, - - u32 => std.fmt.parseInt( - u32, - value orelse return error.ValueRequired, - 0, - ) catch return error.InvalidValue, - - u64 => std.fmt.parseInt( - u64, + inline u8, + u16, + u32, + u64, + usize, + i8, + i16, + i32, + i64, + isize, + => |Int| std.fmt.parseInt( + Int, value orelse return error.ValueRequired, 0, ) catch return error.InvalidValue, From 06376fcb0b0e0e5175f6483c1721fee20d411f4f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 09:30:18 -0800 Subject: [PATCH 119/428] terminal/new: clone can take a shared pool --- build.zig | 2 +- src/bench/screen-copy.sh | 2 + src/bench/screen-copy.zig | 31 +++++++++- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 110 +++++++++++++++++++++++----------- src/terminal/new/Screen.zig | 17 +++++- src/terminal/new/page.zig | 1 + 7 files changed, 125 insertions(+), 39 deletions(-) diff --git a/build.zig b/build.zig index de008af35a..958e21a792 100644 --- a/build.zig +++ b/build.zig @@ -1360,7 +1360,7 @@ fn benchSteps( .target = target, // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, + .optimize = .Debug, }); c_exe.linkLibC(); if (install) b.installArtifact(c_exe); diff --git a/src/bench/screen-copy.sh b/src/bench/screen-copy.sh index 7b4a3d1d20..1bb505d63f 100755 --- a/src/bench/screen-copy.sh +++ b/src/bench/screen-copy.sh @@ -7,6 +7,8 @@ hyperfine \ --warmup 10 \ -n new \ "./zig-out/bin/bench-screen-copy --mode=new${ARGS}" \ + -n new-pooled \ + "./zig-out/bin/bench-screen-copy --mode=new-pooled${ARGS}" \ -n old \ "./zig-out/bin/bench-screen-copy --mode=old${ARGS}" diff --git a/src/bench/screen-copy.zig b/src/bench/screen-copy.zig index b55331d467..1c2b05153f 100644 --- a/src/bench/screen-copy.zig +++ b/src/bench/screen-copy.zig @@ -11,7 +11,7 @@ const Args = struct { mode: Mode = .old, /// The number of times to loop. - count: usize = 5_000, + count: usize = 2500, /// Rows and cols in the terminal. rows: usize = 100, @@ -32,6 +32,7 @@ const Mode = enum { /// Use a memory pool to allocate pages from a backing buffer. new, + @"new-pooled", }; pub const std_options: std.Options = .{ @@ -68,6 +69,16 @@ pub fn main() !void { defer t.deinit(alloc); try benchNew(alloc, &t, args); }, + + .@"new-pooled" => { + var t = try terminal.new.Terminal.init( + alloc, + @intCast(args.cols), + @intCast(args.rows), + ); + defer t.deinit(alloc); + try benchNewPooled(alloc, &t, args); + }, } } @@ -104,3 +115,21 @@ noinline fn benchNew(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !v errdefer s.deinit(); } } + +noinline fn benchNewPooled(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + var pool = try terminal.new.PageList.MemoryPool.init(alloc, std.heap.page_allocator, 4); + defer pool.deinit(); + + for (0..args.count) |_| { + var s = try t.screen.clonePool(alloc, &pool, .{ .active = .{} }, null); + errdefer s.deinit(); + } +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3143eb2151..f9581b738e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1136,6 +1136,7 @@ pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) ! 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]); diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 9de7fa889f..e6972f78cc 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -30,7 +30,7 @@ const page_preheat = 4; const List = std.DoublyLinkedList(Page); /// The memory pool we get page nodes from. -const Pool = std.heap.MemoryPool(List.Node); +const NodePool = std.heap.MemoryPool(List.Node); const std_capacity = pagepkg.std_capacity; @@ -42,13 +42,40 @@ const PagePool = std.heap.MemoryPoolAligned( std.mem.page_size, ); -/// The allocator to use for pages. -alloc: Allocator, +/// The pool of memory used for a pagelist. This can be shared between +/// multiple pagelists but it is not threadsafe. +pub const MemoryPool = struct { + nodes: NodePool, + pages: PagePool, + + pub const ResetMode = std.heap.ArenaAllocator.ResetMode; + + pub fn init( + gen_alloc: Allocator, + page_alloc: Allocator, + preheat: usize, + ) !MemoryPool { + var pool = try NodePool.initPreheated(gen_alloc, preheat); + errdefer pool.deinit(); + var page_pool = try PagePool.initPreheated(page_alloc, preheat); + errdefer page_pool.deinit(); + return .{ .nodes = pool, .pages = page_pool }; + } + + pub fn deinit(self: *MemoryPool) void { + self.pages.deinit(); + self.nodes.deinit(); + } -/// The memory pool we get page nodes for the linked list from. -pool: Pool, + pub fn reset(self: *MemoryPool, mode: ResetMode) void { + _ = self.pages.reset(mode); + _ = self.nodes.reset(mode); + } +}; -page_pool: PagePool, +/// The memory pool we get page nodes, pages from. +pool: MemoryPool, +pool_owned: bool, /// The list of pages in the screen. pages: List, @@ -120,14 +147,10 @@ pub fn init( // The screen starts with a single page that is the entire viewport, // and we'll split it thereafter if it gets too large and add more as // necessary. - var pool = try Pool.initPreheated(alloc, page_preheat); - errdefer pool.deinit(); + var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); - var page_pool = try PagePool.initPreheated(std.heap.page_allocator, page_preheat); - errdefer page_pool.deinit(); - - var page = try pool.create(); - const page_buf = try page_pool.create(); + var page = try pool.nodes.create(); + const page_buf = try pool.pages.create(); // no errdefer because the pool deinit will clean these up // In runtime safety modes we have to memset because the Zig allocator @@ -160,11 +183,10 @@ pub fn init( ); return .{ - .alloc = alloc, .cols = cols, .rows = rows, .pool = pool, - .page_pool = page_pool, + .pool_owned = true, .pages = page_list, .page_size = page_size, .max_size = max_size_actual, @@ -172,11 +194,16 @@ pub fn init( }; } +/// Deinit the pagelist. If you own the memory pool (used clonePool) then +/// this will reset the pool and retain capacity. pub fn deinit(self: *PageList) void { // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. - self.page_pool.deinit(); - self.pool.deinit(); + if (self.pool_owned) { + self.pool.deinit(); + } else { + self.pool.reset(.{ .retain_capacity = {} }); + } } /// Clone this pagelist from the top to bottom (inclusive). @@ -192,32 +219,44 @@ pub fn clone( top: point.Point, bot: ?point.Point, ) !PageList { - var it = self.pageIterator(top, bot); - // First, count our pages so our preheat is exactly what we need. + var it = self.pageIterator(top, bot); const page_count: usize = page_count: { - // Copy the iterator so we don't mutate our original. - var count_it = it; var count: usize = 0; - while (count_it.next()) |_| count += 1; + while (it.next()) |_| count += 1; break :page_count count; }; // Setup our pools - var pool = try Pool.initPreheated(alloc, page_count); + var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_count); errdefer pool.deinit(); - var page_pool = try PagePool.initPreheated(std.heap.page_allocator, page_count); - errdefer page_pool.deinit(); + + var result = try self.clonePool(&pool, top, bot); + result.pool_owned = true; + return result; +} + +/// Like clone, but specify your own memory pool. This is advanced but +/// lets you avoid expensive syscalls to allocate memory. +pub fn clonePool( + self: *const PageList, + pool: *MemoryPool, + top: point.Point, + bot: ?point.Point, +) !PageList { + var it = self.pageIterator(top, bot); // Copy our pages var page_list: List = .{}; var total_rows: usize = 0; + var page_count: usize = 0; while (it.next()) |chunk| { // Clone the page - const page = try pool.create(); - const page_buf = try page_pool.create(); + const page = try pool.nodes.create(); + const page_buf = try pool.pages.create(); page.* = .{ .data = chunk.page.data.cloneBuf(page_buf) }; page_list.append(page); + page_count += 1; // If this is a full page then we're done. if (chunk.fullPage()) { @@ -251,9 +290,8 @@ pub fn clone( } var result: PageList = .{ - .alloc = alloc, - .pool = pool, - .page_pool = page_pool, + .pool = pool.*, + .pool_owned = false, .pages = page_list, .page_size = PagePool.item_size * page_count, .max_size = self.max_size, @@ -434,11 +472,11 @@ pub fn grow(self: *PageList) !?*List.Node { /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. fn createPage(self: *PageList) !*List.Node { - var page = try self.pool.create(); - errdefer self.pool.destroy(page); + var page = try self.pool.nodes.create(); + errdefer self.pool.nodes.destroy(page); - const page_buf = try self.page_pool.create(); - errdefer self.page_pool.destroy(page_buf); + const page_buf = try self.pool.pages.create(); + errdefer self.pool.pages.destroy(page_buf); if (comptime std.debug.runtime_safety) @memset(page_buf, 0); page.* = .{ @@ -548,8 +586,8 @@ fn erasePage(self: *PageList, page: *List.Node) void { // Reset the page memory and return it back to the pool. @memset(page.data.memory, 0); - self.page_pool.destroy(@ptrCast(page.data.memory.ptr)); - self.pool.destroy(page); + self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); + self.pool.nodes.destroy(page); } /// Get the top-left of the screen for the given tag. diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 30021baf86..e1632d8fab 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -194,7 +194,22 @@ pub fn clone( top: point.Point, bot: ?point.Point, ) !Screen { - var pages = try self.pages.clone(alloc, top, bot); + 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 .{ diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 5673e2c903..24a31ed918 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -200,6 +200,7 @@ pub const Page = struct { // This is a memcpy. We may want to investigate if there are // faster ways to do this (i.e. copy-on-write tricks) but I suspect // they'll be slower. I haven't experimented though. + std.log.warn("copy bytes={}", .{self.memory.len}); fastmem.copy(u8, result.memory, self.memory); return result; From e903d5ed2289612f0c78c1416548f8db38bfb929 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 09:31:22 -0800 Subject: [PATCH 120/428] terminal: remove old logs --- src/terminal/Screen.zig | 2 +- src/terminal/new/page.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f9581b738e..d069a7f679 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1136,7 +1136,7 @@ pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) ! assert(dst[1].len == 0); // Perform the copy - std.log.warn("copy bytes={}", .{src[0].len + src[1].len}); + // 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]); diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 24a31ed918..df443b4c37 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -200,7 +200,7 @@ pub const Page = struct { // This is a memcpy. We may want to investigate if there are // faster ways to do this (i.e. copy-on-write tricks) but I suspect // they'll be slower. I haven't experimented though. - std.log.warn("copy bytes={}", .{self.memory.len}); + // std.log.warn("copy bytes={}", .{self.memory.len}); fastmem.copy(u8, result.memory, self.memory); return result; From ee6344eac8b02de582158e553fc1d2b041024966 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 09:44:08 -0800 Subject: [PATCH 121/428] terminal/new: screen clone tests --- src/terminal/Screen.zig | 6 ++ src/terminal/new/Screen.zig | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d069a7f679..5ed7d70a15 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4339,6 +4339,7 @@ test "Screen: history region with scrollback" { } } +// X - don't need this, internal API test "Screen: row copy" { const testing = std.testing; const alloc = testing.allocator; @@ -4357,6 +4358,7 @@ test "Screen: row copy" { try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); } +// X test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; @@ -4387,6 +4389,7 @@ test "Screen: clone" { } } +// X test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; @@ -4405,6 +4408,7 @@ test "Screen: clone empty viewport" { } } +// X test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; @@ -4424,6 +4428,7 @@ test "Screen: clone one line viewport" { } } +// X test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator; @@ -4442,6 +4447,7 @@ test "Screen: clone empty active" { } } +// X test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index e1632d8fab..1b60716081 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1524,3 +1524,130 @@ test "Screen: clone partial" { 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); + } +} From 07eaedf1fb804dc8b108f7228ce1ebd303e4f678 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 10:40:52 -0800 Subject: [PATCH 122/428] terminal/new: eraseRows viewport behavior --- src/terminal/Screen.zig | 10 +++ src/terminal/new/PageList.zig | 112 +++++++++++++++++++++++++++------- src/terminal/new/Screen.zig | 58 ++++++++++++++++++ 3 files changed, 159 insertions(+), 21 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5ed7d70a15..0bdb7a285c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5170,6 +5170,7 @@ test "Screen: promtpPath" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp single" { const testing = std.testing; const alloc = testing.allocator; @@ -5187,6 +5188,7 @@ test "Screen: scrollRegionUp single" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp same line" { const testing = std.testing; const alloc = testing.allocator; @@ -5204,6 +5206,7 @@ test "Screen: scrollRegionUp same line" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp single with pen" { const testing = std.testing; const alloc = testing.allocator; @@ -5228,6 +5231,7 @@ test "Screen: scrollRegionUp single with pen" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp multiple" { const testing = std.testing; const alloc = testing.allocator; @@ -5245,6 +5249,7 @@ test "Screen: scrollRegionUp multiple" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp multiple count" { const testing = std.testing; const alloc = testing.allocator; @@ -5262,6 +5267,7 @@ test "Screen: scrollRegionUp multiple count" { } } +// 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; @@ -5278,6 +5284,7 @@ test "Screen: scrollRegionUp count greater than available lines" { 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; @@ -5302,6 +5309,7 @@ test "Screen: scrollRegionUp fills with pen" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp buffer wrap" { const testing = std.testing; const alloc = testing.allocator; @@ -5334,6 +5342,7 @@ test "Screen: scrollRegionUp buffer wrap" { } } +// X - we don't use this in new terminal test "Screen: scrollRegionUp buffer wrap alternate" { const testing = std.testing; const alloc = testing.allocator; @@ -5366,6 +5375,7 @@ test "Screen: scrollRegionUp buffer wrap alternate" { } } +// 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; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index e6972f78cc..edd68b6577 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -319,6 +319,34 @@ pub fn clonePool( return result; } +/// Returns the viewport for the given offset, prefering to pin to +/// "active" if the offset is within the active area. +fn viewportForOffset(self: *const PageList, offset: RowOffset) Viewport { + // If the offset is on the active page, then we pin to active + // if our row idx is beyond the active row idx. + const active = self.getTopLeft(.active); + if (offset.page == active.page) { + if (offset.row_offset >= active.row_offset) { + return .{ .active = {} }; + } + } else { + var page_ = active.page.next; + while (page_) |page| { + // This loop is pretty fast because the active area is + // never that large so this is at most one, two pages for + // reasonable terminals (including very large real world + // ones). + + // A page forward in the active area is our page, so we're + // definitely in the active area. + if (page == offset.page) return .{ .active = {} }; + page_ = page.next; + } + } + + return .{ .exact = offset }; +} + /// Scroll options. pub const Scroll = union(enum) { /// Scroll to the active area. This is also sometimes referred to as @@ -343,7 +371,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { .active => self.viewport = .{ .active = {} }, .top => self.viewport = .{ .top = {} }, - .delta_row => |n| delta_row: { + .delta_row => |n| { if (n == 0) return; const top = self.getTopLeft(.viewport); @@ -362,25 +390,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // But in a terminal when you get to the bottom and back into the // active area, you usually expect that the viewport will now // follow the active area. - const active = self.getTopLeft(.active); - if (offset.page == active.page) { - if (offset.row_offset >= active.row_offset) { - self.viewport = .{ .active = {} }; - break :delta_row; - } - } else active: { - // Check forward pages too. - var page = active.page.next orelse break :active; - while (true) { - if (page == offset.page) { - self.viewport = .{ .active = {} }; - break :delta_row; - } - page = page.next orelse break :active; - } - } - - self.viewport = .{ .exact = offset }; + self.viewport = self.viewportForOffset(offset); }, } } @@ -562,6 +572,23 @@ pub fn eraseRows( return; }; } + + // If we have an exact viewport, we need to adjust for active area. + switch (self.viewport) { + .active => {}, + + .exact => |offset| self.viewport = self.viewportForOffset(offset), + + // For top, we move back to active if our erasing moved our + // top page into the active area. + .top => { + const vp = self.viewportForOffset(.{ + .page = self.pages.first.?, + .row_offset = 0, + }); + if (vp == .active) self.viewport = vp; + }, + } } /// Erase a single page, freeing all its resources. The page can be @@ -1479,7 +1506,7 @@ test "PageList erase" { try testing.expectEqual(s.rows, s.totalRows()); } -test "PageList erase resets viewport if inside erased page" { +test "PageList erase resets viewport to active if moves within active" { const testing = std.testing; const alloc = testing.allocator; @@ -1498,9 +1525,52 @@ test "PageList erase resets viewport if inside erased page" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.viewport == .active); +} + +test "PageList erase resets viewport if inside erased page but not active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + try testing.expect(s.viewport.exact.page == s.pages.first.?); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 2 } }); try testing.expect(s.viewport.exact.page == s.pages.first.?); } +test "PageList erase resets viewport to active if top is inside active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Move our viewport to the top + s.scroll(.{ .top = {} }); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.viewport == .active); +} + test "PageList erase active regrows automatically" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 1b60716081..4aa67e3a32 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1651,3 +1651,61 @@ test "Screen: clone one line active with extra space" { 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); + } +} From 07639e48ab4d509f7e219070644f7e45b0cc8271 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 10:46:55 -0800 Subject: [PATCH 123/428] terminal/new: more screen tests --- src/terminal/Screen.zig | 3 ++ src/terminal/new/Screen.zig | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0bdb7a285c..9e7c928613 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5414,6 +5414,7 @@ test "Screen: scrollRegionUp buffer wrap alternative with extra lines" { } } +// X test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; @@ -5438,6 +5439,7 @@ test "Screen: clear history with no history" { } } +// X test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator; @@ -5472,6 +5474,7 @@ test "Screen: clear history" { } } +// X test "Screen: clear above cursor" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 4aa67e3a32..46a47f8290 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1709,3 +1709,58 @@ test "Screen: clear history" { 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); +} From e5cb77fe629637d2dd1b605305a2daee6d7f9f36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 10:53:21 -0800 Subject: [PATCH 124/428] terminal: mark off test --- src/terminal/Screen.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9e7c928613..02f3706299 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5500,6 +5500,7 @@ test "Screen: clear above cursor" { 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; From 43ad442ffef6a96a630f0e653bc1b85856be2ebd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 11:08:23 -0800 Subject: [PATCH 125/428] terminal/new: screen resize stubs (don't work) --- src/terminal/Screen.zig | 2 + src/terminal/new/Screen.zig | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 02f3706299..3614e92dba 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6035,6 +6035,7 @@ test "Screen: dirty with graphemes" { } } +// X test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; @@ -6061,6 +6062,7 @@ test "Screen: resize (no reflow) more rows" { 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; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 46a47f8290..69f1d53d67 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -564,6 +564,45 @@ fn blankCell(self: *const Screen) Cell { 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. +pub fn resize( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + if (self.pages.cols == cols) { + // No resize necessary + if (self.pages.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; + } + + @panic("TODO"); +} + +/// 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: size.CellCountInt, + cols: size.CellCountInt, +) !void { + // If we're resizing to the same size, do nothing. + if (self.pages.cols == cols and self.pages.rows == rows) return; +} + /// Set a style attribute for the current cursor. /// /// This can cause a page split if the current page cannot fit this style. @@ -1764,3 +1803,38 @@ test "Screen: clear above cursor with history" { 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 s.resizeWithoutReflow(10, 2); +// +// { +// const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); +// defer alloc.free(contents); +// try testing.expectEqualStrings("2EFGH\n3IJKL", contents); +// } +// } From 5009ab6645d75accfc4635ce6f6086b4d6e87c80 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 11:29:39 -0800 Subject: [PATCH 126/428] terminal/new: page resizebuf boilerplate --- src/terminal/new/page.zig | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index df443b4c37..02c8dbd11b 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -206,6 +206,35 @@ pub const Page = struct { return result; } + /// Clone and resize this page to the new layout with a provided + /// memory buffer. The new layout can change any parameters. If they + /// do not fit within the new layout, OutOfMemory is returned. + /// If OutOfMemory is returned, the memory buffer is not zeroed; + /// the caller should zero if it is reused. + /// + /// If reflow is true, soft-wrapped text will be reflowed. If reflow + /// is false then soft-wrapped text will be truncated. + /// + /// For deleted cells, this will reclaim the grapheme/style memory + /// as appropriate. A page has no concept of the current active style + /// so if you want the current active style to not be GCd then you + /// should increase the ref count before calling this function, and + /// decrease it after. + pub fn resizeBuf( + self: *Page, + buf: OffsetBuf, + l: Layout, + reflow: bool, + ) !Page { + // TODO + if (reflow) @panic("TODO"); + + // Non-reflow resize is relatively simple. + _ = self; + _ = buf; + _ = l; + } + /// Get a single row. y must be valid. pub fn getRow(self: *const Page, y: usize) *Row { assert(y < self.size.rows); From f04d26442f3809a834cdff384f457c76ed14bcf1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 20:02:21 -0800 Subject: [PATCH 127/428] terminal/new: pagelist resize rows only no reflow --- src/terminal/new/PageList.zig | 128 ++++++++++++++++++++++++++++++++++ src/terminal/new/page.zig | 29 -------- 2 files changed, 128 insertions(+), 29 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index edd68b6577..5fa6304000 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -347,6 +347,63 @@ fn viewportForOffset(self: *const PageList, offset: RowOffset) Viewport { return .{ .exact = offset }; } +/// Resize options +pub const Resize = struct { + /// The new cols/cells of the screen. + cols: ?size.CellCountInt = null, + rows: ?size.CellCountInt = null, + + /// Whether to reflow the text. If this is false then the text will + /// be truncated if the new size is smaller than the old size. + reflow: bool = true, +}; + +/// Resize +/// TODO: docs +pub fn resize(self: *PageList, opts: Resize) !void { + if (!opts.reflow) return try self.resizeWithoutReflow(opts); +} + +fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { + assert(!opts.reflow); + + // If we're only changing the number of rows, it is a very fast operation. + if (opts.cols == null) { + const rows = opts.rows orelse return; + switch (std.math.order(rows, self.rows)) { + .eq => {}, + + // Making rows smaller, we simply change our rows value. Changing + // the row size doesn't affect anything else since max size and + // so on are all byte-based. + .lt => self.rows = rows, + + // Making rows larger we adjust our row count, and then grow + // to the row count. + .gt => gt: { + self.rows = rows; + + // Perform a quick count to make sure we have at least + // the number of rows we need. This should be fast because + // we only need to count up to "rows" + var count: usize = 0; + var page = self.pages.first; + while (page) |p| : (page = p.next) { + count += p.data.size.rows; + if (count >= rows) break :gt; + } + + assert(count < rows); + for (count..rows) |_| _ = try self.grow(); + }, + } + + return; + } + + @panic("TODO"); +} + /// Scroll options. pub const Scroll = union(enum) { /// Scroll to the active area. This is also sometimes referred to as @@ -1665,3 +1722,74 @@ test "PageList clone less than active" { defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); } + +test "PageList resize (no reflow) more rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + // Resize + try s.resize(.{ .rows = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + +test "PageList resize (no reflow) more rows with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, null); + defer s.deinit(); + try s.growRows(50); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 50, + } }, pt); + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 53), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 48, + } }, pt); + } +} + +test "PageList resize (no reflow) less rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 02c8dbd11b..df443b4c37 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -206,35 +206,6 @@ pub const Page = struct { return result; } - /// Clone and resize this page to the new layout with a provided - /// memory buffer. The new layout can change any parameters. If they - /// do not fit within the new layout, OutOfMemory is returned. - /// If OutOfMemory is returned, the memory buffer is not zeroed; - /// the caller should zero if it is reused. - /// - /// If reflow is true, soft-wrapped text will be reflowed. If reflow - /// is false then soft-wrapped text will be truncated. - /// - /// For deleted cells, this will reclaim the grapheme/style memory - /// as appropriate. A page has no concept of the current active style - /// so if you want the current active style to not be GCd then you - /// should increase the ref count before calling this function, and - /// decrease it after. - pub fn resizeBuf( - self: *Page, - buf: OffsetBuf, - l: Layout, - reflow: bool, - ) !Page { - // TODO - if (reflow) @panic("TODO"); - - // Non-reflow resize is relatively simple. - _ = self; - _ = buf; - _ = l; - } - /// Get a single row. y must be valid. pub fn getRow(self: *const Page, y: usize) *Row { assert(y < self.size.rows); From 99b9d6fe8c20461a452a3c8a1d1ad690677c9be9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 21:43:32 -0800 Subject: [PATCH 128/428] terminal/new: resize no reflow pagelist less columns --- src/terminal/new/PageList.zig | 79 +++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 5fa6304000..6ab3f42c10 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -397,11 +397,36 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { for (count..rows) |_| _ = try self.grow(); }, } - - return; } - @panic("TODO"); + if (opts.cols) |cols| { + switch (std.math.order(cols, self.cols)) { + .eq => {}, + + // Making our columns smaller. We always have space for this + // in existing pages so we need to go through the pages, + // resize the columns, and clear any cells that are beyond + // the new size. + .lt => { + var it = self.pageIterator(.{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.page.data; + const rows = page.rows.ptr(page.memory); + for (0..page.size.rows) |i| { + const row = &rows[i]; + page.clearCells(row, cols, self.cols); + } + + page.size.cols = cols; + } + }, + + // Make our columns larger. This is a bit more complicated because + // pages may not have the capacity for this. If they don't have + // the capacity we need to allocate a new page and copy the data. + .gt => @panic("TODO"), + } + } } /// Scroll options. @@ -1793,3 +1818,51 @@ test "PageList resize (no reflow) less rows" { } }, pt); } } + +test "PageList resize (no reflow) less cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize (no reflow) less cols clears graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Add a grapheme. + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(9, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, 'A'); + } + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.pageIterator(.{ .screen = .{} }, null); + while (it.next()) |chunk| { + try testing.expectEqual(@as(usize, 0), chunk.page.data.graphemeCount()); + } +} From 4566304e1d05c84228813c55ae32948c763d6afc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 21:53:42 -0800 Subject: [PATCH 129/428] terminal/new: pagelist more resize fixes --- src/terminal/new/PageList.zig | 57 ++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 6ab3f42c10..19b8bd909c 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -11,6 +11,7 @@ const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); const size = @import("size.zig"); const OffsetBuf = size.OffsetBuf; +const Capacity = pagepkg.Capacity; const Page = pagepkg.Page; const Row = pagepkg.Row; @@ -419,12 +420,38 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { page.size.cols = cols; } + + self.cols = cols; }, // Make our columns larger. This is a bit more complicated because // pages may not have the capacity for this. If they don't have // the capacity we need to allocate a new page and copy the data. - .gt => @panic("TODO"), + .gt => { + const cap = try std_capacity.adjust(.{ .cols = cols }); + + var it = self.pageIterator(.{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.page.data; + + // Unlikely fast path: we have capacity in the page. This + // is only true if we resized to less cols earlier. + if (page.capacity.cols >= cols) { + if (true) @panic("TODO: TEST"); + page.size.cols = cols; + continue; + } + + // Likely slow path: we don't have capacity, so we need + // to allocate a page, and copy the old data into it. + // TODO: handle capacity can't fit rows for cols + if (true) @panic("TODO after page.cloneFrom"); + const new_page = try self.createPage(cap); + _ = new_page; + } + + self.cols = cols; + }, } } } @@ -548,7 +575,7 @@ pub fn grow(self: *PageList) !?*List.Node { } // We need to allocate a new memory buffer. - const next_page = try self.createPage(); + const next_page = try self.createPage(try std_capacity.adjust(.{ .cols = self.cols })); // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_page); @@ -563,7 +590,7 @@ pub fn grow(self: *PageList) !?*List.Node { /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. -fn createPage(self: *PageList) !*List.Node { +fn createPage(self: *PageList, cap: Capacity) !*List.Node { var page = try self.pool.nodes.create(); errdefer self.pool.nodes.destroy(page); @@ -574,7 +601,7 @@ fn createPage(self: *PageList) !*List.Node { page.* = .{ .data = Page.initBuf( OffsetBuf.init(page_buf), - Page.layout(try std_capacity.adjust(.{ .cols = self.cols })), + Page.layout(cap), ), }; page.data.size.rows = 0; @@ -1828,6 +1855,7 @@ test "PageList resize (no reflow) less cols" { // Resize try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.totalRows()); var it = s.rowIterator(.{ .screen = .{} }, null); @@ -1859,6 +1887,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { // Resize try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.totalRows()); var it = s.pageIterator(.{ .screen = .{} }, null); @@ -1866,3 +1895,23 @@ test "PageList resize (no reflow) less cols clears graphemes" { try testing.expectEqual(@as(usize, 0), chunk.page.data.graphemeCount()); } } + +// test "PageList resize (no reflow) more cols" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var s = try init(alloc, 5, 3, 0); +// defer s.deinit(); +// +// // Resize +// try s.resize(.{ .cols = 10, .reflow = false }); +// try testing.expectEqual(@as(usize, 10), s.cols); +// try testing.expectEqual(@as(usize, 3), s.totalRows()); +// +// var it = s.rowIterator(.{ .screen = .{} }, null); +// while (it.next()) |offset| { +// const rac = offset.rowAndCell(0); +// const cells = offset.page.data.getCells(rac.row); +// try testing.expectEqual(@as(usize, 10), cells.len); +// } +// } From f6071ca53e19fb0f54fd261c8a6bacc6c7197796 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 22:13:10 -0800 Subject: [PATCH 130/428] terminal/new: page.cloneFrom --- src/terminal/new/page.zig | 191 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index df443b4c37..ac037d5d7e 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -206,6 +206,72 @@ pub const Page = struct { return result; } + /// Clone the contents of another page into this page. The capacities + /// can be different, but the size of the other page must fit into + /// this page. + /// + /// The y_start and y_end parameters allow you to clone only a portion + /// of the other page. This is useful for splitting a page into two + /// or more pages. + /// + /// The column count of this page will always be the same as this page. + /// If the other page has more columns, the extra columns will be + /// truncated. If the other page has fewer columns, the extra columns + /// will be zeroed. + /// + /// The current page is assumed to be empty. We will not clear any + /// existing data in the current page. + pub fn cloneFrom( + self: *Page, + other: *const Page, + y_start: usize, + y_end: usize, + ) !void { + assert(y_start <= y_end); + assert(y_end <= other.size.rows); + assert(y_end - y_start <= self.size.rows); + if (comptime std.debug.runtime_safety) { + // The current page must be empty. + assert(self.styles.count(self.memory) == 0); + assert(self.graphemeCount() == 0); + } + + const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; + const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; + for (rows, other_rows) |*dst_row, *src_row| { + // Copy all the row metadata but keep our cells offset + const cells_offset = dst_row.cells; + dst_row.* = src_row.*; + dst_row.cells = cells_offset; + + const cell_len = @min(self.size.cols, other.size.cols); + const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; + const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; + + // If we have no managed memory in the row, we can just copy. + if (!dst_row.grapheme and !dst_row.styled) { + fastmem.copy(Cell, cells, other_cells); + continue; + } + + // We have managed memory, so we have to do a slower copy to + // get all of that right. + for (cells, other_cells) |*dst_cell, *src_cell| { + dst_cell.* = src_cell.*; + if (src_cell.hasGrapheme()) { + const cps = other.lookupGrapheme(src_cell).?; + for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); + } + if (src_cell.style_id != style.default_id) { + const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; + const md = try self.styles.upsert(self.memory, other_style); + md.ref += 1; + dst_cell.style_id = md.id; + } + } + } + } + /// Get a single row. y must be valid. pub fn getRow(self: *const Page, y: usize) *Row { assert(y < self.size.rows); @@ -944,3 +1010,128 @@ test "Page clone" { try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); } } + +test "Page cloneFrom" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom shrink columns" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 5, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + try testing.expectEqual(@as(size.CellCountInt, 5), page2.size.cols); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, 5); + + // Read it again + for (0..5) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + for (5..page2.size.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} From 437980a28d3120fcec79db558e1c36fd3e6f81b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 22:21:31 -0800 Subject: [PATCH 131/428] terminal/new: pagelist more cols --- src/terminal/new/PageList.zig | 89 ++++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 19b8bd909c..9a669e676d 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -437,7 +437,6 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // Unlikely fast path: we have capacity in the page. This // is only true if we resized to less cols earlier. if (page.capacity.cols >= cols) { - if (true) @panic("TODO: TEST"); page.size.cols = cols; continue; } @@ -445,9 +444,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // Likely slow path: we don't have capacity, so we need // to allocate a page, and copy the old data into it. // TODO: handle capacity can't fit rows for cols - if (true) @panic("TODO after page.cloneFrom"); const new_page = try self.createPage(cap); - _ = new_page; + errdefer self.destroyPage(new_page); + new_page.data.size.rows = page.size.rows; + try new_page.data.cloneFrom(page, 0, page.size.rows); + + // Insert our new page before the old page. + // Remove the old page. + // Deallocate the old page. + self.pages.insertBefore(chunk.page, new_page); + self.pages.remove(chunk.page); + self.destroyPage(chunk.page); } self.cols = cols; @@ -609,6 +616,14 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { return page; } +/// Destroy the memory of the given page and return it to the pool. The +/// page is assumed to already be removed from the linked list. +fn destroyPage(self: *PageList, page: *List.Node) void { + @memset(page.data.memory, 0); + self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); + self.pool.nodes.destroy(page); +} + /// Erase the rows from the given top to bottom (inclusive). Erasing /// the rows doesn't clear them but actually physically REMOVES the rows. /// If the top or bottom point is in the middle of a page, the other @@ -719,11 +734,7 @@ fn erasePage(self: *PageList, page: *List.Node) void { // Remove the page from the linked list self.pages.remove(page); - - // Reset the page memory and return it back to the pool. - @memset(page.data.memory, 0); - self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); - self.pool.nodes.destroy(page); + self.destroyPage(page); } /// Get the top-left of the screen for the given tag. @@ -1896,22 +1907,46 @@ test "PageList resize (no reflow) less cols clears graphemes" { } } -// test "PageList resize (no reflow) more cols" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s = try init(alloc, 5, 3, 0); -// defer s.deinit(); -// -// // Resize -// try s.resize(.{ .cols = 10, .reflow = false }); -// try testing.expectEqual(@as(usize, 10), s.cols); -// try testing.expectEqual(@as(usize, 3), s.totalRows()); -// -// var it = s.rowIterator(.{ .screen = .{} }, null); -// while (it.next()) |offset| { -// const rac = offset.rowAndCell(0); -// const cells = offset.page.data.getCells(rac.row); -// try testing.expectEqual(@as(usize, 10), cells.len); -// } -// } +test "PageList resize (no reflow) more cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + } +} + +test "PageList resize (no reflow) less cols then more cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.cols); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} From df1c935a3a84e66e775d996c7d455d037c5fa0a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 22:22:46 -0800 Subject: [PATCH 132/428] terminal/new: pagelist resize rows and cols --- src/terminal/new/PageList.zig | 45 ++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 9a669e676d..6644393f19 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -368,9 +368,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { assert(!opts.reflow); - // If we're only changing the number of rows, it is a very fast operation. - if (opts.cols == null) { - const rows = opts.rows orelse return; + if (opts.rows) |rows| { switch (std.math.order(rows, self.rows)) { .eq => {}, @@ -1950,3 +1948,44 @@ test "PageList resize (no reflow) less cols then more cols" { try testing.expectEqual(@as(usize, 5), cells.len); } } + +test "PageList resize (no reflow) less rows and cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 5, .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 7), s.rows); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} + +test "PageList resize (no reflow) more rows and less cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize less + try s.resize(.{ .cols = 5, .rows = 20, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + try testing.expectEqual(@as(usize, 20), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } +} From baa3903d22bec45cb159245c6f4e9f6c3fc87e4b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 Feb 2024 22:28:14 -0800 Subject: [PATCH 133/428] terminal/new: screen resize no reflow less rows --- src/terminal/new/PageList.zig | 1 + src/terminal/new/Screen.zig | 39 +++++++++++++++++------------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 6644393f19..8a07949d70 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -363,6 +363,7 @@ pub const Resize = struct { /// TODO: docs pub fn resize(self: *PageList, opts: Resize) !void { if (!opts.reflow) return try self.resizeWithoutReflow(opts); + @panic("TODO: resize with text reflow"); } fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 69f1d53d67..34e0380b20 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -584,7 +584,7 @@ pub fn resize( // 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); + try self.resizeWithoutReflow(cols, rows); return; } @@ -596,11 +596,10 @@ pub fn resize( /// with zeros. pub fn resizeWithoutReflow( self: *Screen, - rows: size.CellCountInt, cols: size.CellCountInt, + rows: size.CellCountInt, ) !void { - // If we're resizing to the same size, do nothing. - if (self.pages.cols == cols and self.pages.rows == rows) return; + try self.pages.resize(.{ .rows = rows, .cols = cols, .reflow = false }); } /// Set a style attribute for the current cursor. @@ -1822,19 +1821,19 @@ test "Screen: resize (no reflow) more rows" { } } -// 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 s.resizeWithoutReflow(10, 2); -// -// { -// const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); -// defer alloc.free(contents); -// try testing.expectEqualStrings("2EFGH\n3IJKL", 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 s.resizeWithoutReflow(10, 2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} From 9269d70f031cd3e79ed3c555c80aaa4f2d32377a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 13:19:39 -0800 Subject: [PATCH 134/428] terminal/new: resize less rows trims blank lines first --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 130 +++++++++++++++++++++++++++++++++- src/terminal/new/Screen.zig | 32 +++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3614e92dba..1f12b80954 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6080,6 +6080,7 @@ test "Screen: resize (no reflow) less rows" { } } +// X test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 8a07949d70..5cdce89972 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -366,6 +366,67 @@ pub fn resize(self: *PageList, opts: Resize) !void { @panic("TODO: resize with text reflow"); } +/// Returns the number of trailing blank lines, not to exceed max. Max +/// is used to limit our traversal in the case of large scrollback. +fn trailingBlankLines( + self: *const PageList, + max: size.CellCountInt, +) size.CellCountInt { + var count: size.CellCountInt = 0; + + // Go through our pages backwards since we're counting trailing blanks. + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + const len = page.data.size.rows; + const rows = page.data.rows.ptr(page.data.memory)[0..len]; + for (0..len) |i| { + const rev_i = len - i - 1; + const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return count; + + // Inc count, if we're beyond max then we're done. + count += 1; + if (count >= max) return count; + } + } + + return count; +} + +/// Trims up to max trailing blank rows from the pagelist and returns the +/// number of rows trimmed. A blank row is any row with no text (but may +/// have styling). +fn trimTrailingBlankRows( + self: *PageList, + max: size.CellCountInt, +) size.CellCountInt { + var trimmed: size.CellCountInt = 0; + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + const len = page.data.size.rows; + const rows_slice = page.data.rows.ptr(page.data.memory)[0..len]; + for (0..len) |i| { + const rev_i = len - i - 1; + const row = &rows_slice[rev_i]; + const cells = row.cells.ptr(page.data.memory)[0..page.data.size.cols]; + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return trimmed; + + // No text, we can trim this row. Because it has + // no text we can also be sure it has no styling + // so we don't need to worry about memory. + page.data.size.rows -= 1; + trimmed += 1; + if (trimmed >= max) return trimmed; + } + } + + return trimmed; +} + fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { assert(!opts.reflow); @@ -376,7 +437,21 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // Making rows smaller, we simply change our rows value. Changing // the row size doesn't affect anything else since max size and // so on are all byte-based. - .lt => self.rows = rows, + .lt => { + // If our rows are shrinking, we prefer to trim trailing + // blank lines from the active area instead of creating + // history if we can. + // + // 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. + _ = self.trimTrailingBlankRows(self.rows - rows); + + // If we didn't trim enough, just modify our row count and this + // will create additional history. + self.rows = rows; + }, // Making rows larger we adjust our row count, and then grow // to the row count. @@ -1843,6 +1918,19 @@ test "PageList resize (no reflow) less rows" { defer s.deinit(); try testing.expectEqual(@as(usize, 10), s.totalRows()); + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + // Resize try s.resize(.{ .rows = 5, .reflow = false }); try testing.expectEqual(@as(usize, 5), s.rows); @@ -1856,6 +1944,46 @@ test "PageList resize (no reflow) less rows" { } } +test "PageList resize (no reflow) less rows trims blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Resize + try s.resize(.{ .rows = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.rows); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + test "PageList resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 34e0380b20..1f5be829fb 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1837,3 +1837,35 @@ test "Screen: resize (no reflow) less rows" { 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); + } +} From 2e21f2179ded11fe140809a468fa135f6cd5b7ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 13:30:05 -0800 Subject: [PATCH 135/428] terminal/new: port lots of no reflow screen resizes --- src/terminal/Screen.zig | 5 ++ src/terminal/new/PageList.zig | 40 +++++++++++++ src/terminal/new/Screen.zig | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1f12b80954..38ddce58f0 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6116,6 +6116,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { } } +// X test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; @@ -6151,6 +6152,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { } } +// X test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; @@ -6168,6 +6170,7 @@ test "Screen: resize (no reflow) more cols" { } } +// X test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; @@ -6186,6 +6189,7 @@ test "Screen: resize (no reflow) less cols" { } } +// X test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator; @@ -6203,6 +6207,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { } } +// X test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 5cdce89972..fbb98b271d 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -1984,6 +1984,46 @@ test "PageList resize (no reflow) less rows trims blank lines" { } } +test "PageList resize (no reflow) more rows extends blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Resize + try s.resize(.{ .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 7), s.rows); + try testing.expectEqual(@as(usize, 7), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } +} + test "PageList resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 1f5be829fb..0ed8a32297 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1869,3 +1869,105 @@ test "Screen: resize (no reflow) less rows trims blank lines" { 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); + } +} From eb3323940d08ea8b9acc7180d5430b1b813e87de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 13:32:53 -0800 Subject: [PATCH 136/428] terminal/new: more no reflow tests --- src/terminal/Screen.zig | 3 +++ src/terminal/new/PageList.zig | 21 +++++++++++++++++++++ src/terminal/new/Screen.zig | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 38ddce58f0..3ac42f411d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6226,6 +6226,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { } } +// X // https://github.com/mitchellh/ghostty/issues/1030 test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; @@ -6251,6 +6252,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { } } +// X test "Screen: resize (no reflow) empty screen" { const testing = std.testing; const alloc = testing.allocator; @@ -6268,6 +6270,7 @@ test "Screen: resize (no reflow) empty screen" { try testing.expectEqual(@as(usize, 10), s.rowsCapacity()); } +// X test "Screen: resize (no reflow) grapheme copy" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index fbb98b271d..8ae1fe63f8 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -2158,3 +2158,24 @@ test "PageList resize (no reflow) more rows and less cols" { try testing.expectEqual(@as(usize, 5), cells.len); } } + +test "PageList resize (no reflow) empty screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Resize + try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 10), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + } +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 0ed8a32297..2f214c7cd9 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -1971,3 +1971,28 @@ test "Screen: resize (no reflow) less rows with scrollback" { 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); + } +} From 4632dd359ded8a03a45faea223fef5951f7007af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 13:44:13 -0800 Subject: [PATCH 137/428] terminal/new: more no reflow tests --- src/terminal/Screen.zig | 1 + src/terminal/new/Screen.zig | 51 +++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3ac42f411d..20c9dae008 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6313,6 +6313,7 @@ test "Screen: resize (no reflow) grapheme copy" { } } +// X test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 2f214c7cd9..dbacafe9a0 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -863,18 +863,24 @@ fn testWriteString(self: *Screen, text: []const u8) !void { if (c == '\n') { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); + self.cursor.pending_wrap = false; continue; } - if (self.cursor.x == self.pages.cols) { - @panic("wrap not implemented"); - } - const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); if (width == 0) { @panic("zero-width todo"); } + 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 => { @@ -893,7 +899,7 @@ fn testWriteString(self: *Screen, text: []const u8) !void { if (self.cursor.x + 1 < self.pages.cols) { self.cursorRight(1); } else { - @panic("wrap not implemented"); + self.cursor.pending_wrap = true; } }, @@ -1996,3 +2002,38 @@ test "Screen: resize (no reflow) less rows with empty trailing" { 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); + } +} From 2d1ab1e660b2c315a49cbdac1701ec176525ec3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 13:49:14 -0800 Subject: [PATCH 138/428] terminal/new: non-passing resize tests --- src/terminal/new/Screen.zig | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index dbacafe9a0..ca9f003910 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2037,3 +2037,31 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { try testing.expectEqual(wrapped, row.wrap); } } + +// 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); +// } +// } From 9006a3f43101df07ad511f8a283b68fbc114c364 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 14:05:52 -0800 Subject: [PATCH 139/428] bench/resize --- build.zig | 2 +- src/bench/resize.sh | 12 ++++ src/bench/resize.zig | 110 ++++++++++++++++++++++++++++++++++ src/build_config.zig | 1 + src/main.zig | 1 + src/terminal/new/PageList.zig | 23 +++++++ 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100755 src/bench/resize.sh create mode 100644 src/bench/resize.zig diff --git a/build.zig b/build.zig index 958e21a792..de008af35a 100644 --- a/build.zig +++ b/build.zig @@ -1360,7 +1360,7 @@ fn benchSteps( .target = target, // We always want our benchmarks to be in release mode. - .optimize = .Debug, + .optimize = .ReleaseFast, }); c_exe.linkLibC(); if (install) b.installArtifact(c_exe); diff --git a/src/bench/resize.sh b/src/bench/resize.sh new file mode 100755 index 0000000000..8f420bf014 --- /dev/null +++ b/src/bench/resize.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Uncomment to test with an active terminal state. +# ARGS=" --terminal" + +hyperfine \ + --warmup 10 \ + -n new \ + "./zig-out/bin/bench-resize --mode=new${ARGS}" \ + -n old \ + "./zig-out/bin/bench-resize --mode=old${ARGS}" + diff --git a/src/bench/resize.zig b/src/bench/resize.zig new file mode 100644 index 0000000000..53261486e9 --- /dev/null +++ b/src/bench/resize.zig @@ -0,0 +1,110 @@ +//! This benchmark tests the speed of resizing. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli = @import("../cli.zig"); +const terminal = @import("../terminal/main.zig"); + +const Args = struct { + mode: Mode = .old, + + /// The number of times to loop. + count: usize = 10_000, + + /// Rows and cols in the terminal. + rows: usize = 50, + cols: usize = 100, + + /// This is set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + pub fn deinit(self: *Args) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +const Mode = enum { + /// The default allocation strategy of the structure. + old, + + /// Use a memory pool to allocate pages from a backing buffer. + new, +}; + +pub const std_options: std.Options = .{ + .log_level = .debug, +}; + +pub fn main() !void { + // We want to use the c allocator because it is much faster than GPA. + const alloc = std.heap.c_allocator; + + // Parse our args + var args: Args = .{}; + defer args.deinit(); + { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + try cli.args.parse(Args, alloc, &args, &iter); + } + + // Handle the modes that do not depend on terminal state first. + switch (args.mode) { + .old => { + var t = try terminal.Terminal.init(alloc, args.cols, args.rows); + defer t.deinit(alloc); + try benchOld(&t, args); + }, + + .new => { + var t = try terminal.new.Terminal.init( + alloc, + @intCast(args.cols), + @intCast(args.rows), + ); + defer t.deinit(alloc); + try benchNew(&t, args); + }, + } +} + +noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |i| { + const cols: usize, const rows: usize = if (i % 2 == 0) + .{ args.cols * 2, args.rows * 2 } + else + .{ args.cols, args.rows }; + + try t.screen.resizeWithoutReflow(@intCast(rows), @intCast(cols)); + } +} + +noinline fn benchNew(t: *terminal.new.Terminal, args: Args) !void { + // We fill the terminal with letters. + for (0..args.rows) |row| { + for (0..args.cols) |col| { + t.setCursorPos(row + 1, col + 1); + try t.print('A'); + } + } + + for (0..args.count) |i| { + const cols: usize, const rows: usize = if (i % 2 == 0) + .{ args.cols * 2, args.rows * 2 } + else + .{ args.cols, args.rows }; + + try t.screen.resizeWithoutReflow(@intCast(rows), @intCast(cols)); + } +} diff --git a/src/build_config.zig b/src/build_config.zig index 8992af8ad9..c894917b9d 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -144,6 +144,7 @@ pub const ExeEntrypoint = enum { bench_codepoint_width, bench_grapheme_break, bench_page_init, + bench_resize, bench_screen_copy, bench_vt_insert_lines, }; diff --git a/src/main.zig b/src/main.zig index 5d14e927b7..1b83e24d03 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,7 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), .bench_page_init => @import("bench/page-init.zig"), + .bench_resize => @import("bench/resize.zig"), .bench_screen_copy => @import("bench/screen-copy.zig"), .bench_vt_insert_lines => @import("bench/vt-insert-lines.zig"), }; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 8ae1fe63f8..6b3b08ed1b 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -2179,3 +2179,26 @@ test "PageList resize (no reflow) empty screen" { try testing.expectEqual(@as(usize, 10), cells.len); } } + +// test "PageList bug" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var s = try init(alloc, 300, 100, null); +// defer s.deinit(); +// try testing.expect(s.pages.first == s.pages.last); +// const page = &s.pages.first.?.data; +// for (0..s.rows) |y| { +// for (0..s.cols) |x| { +// const rac = page.getRowAndCell(x, y); +// rac.cell.* = .{ +// .content_tag = .codepoint, +// .content = .{ .codepoint = 'A' }, +// }; +// } +// } +// +// // Resize +// try s.resize(.{ .cols = s.cols * 2, .rows = s.rows * 2, .reflow = false }); +// try s.resize(.{ .cols = s.cols / 2, .rows = s.rows / 2, .reflow = false }); +// } From 636e74d273ba093dd055f9ff9a5989a8b16fb473 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 21:01:51 -0800 Subject: [PATCH 140/428] terminal/new: pagelist resize no reflow more cols handles cap change --- src/terminal/new/PageList.zig | 93 ++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 6b3b08ed1b..d70bc88004 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -517,16 +517,34 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // Likely slow path: we don't have capacity, so we need // to allocate a page, and copy the old data into it. - // TODO: handle capacity can't fit rows for cols - const new_page = try self.createPage(cap); - errdefer self.destroyPage(new_page); - new_page.data.size.rows = page.size.rows; - try new_page.data.cloneFrom(page, 0, page.size.rows); - // Insert our new page before the old page. + // On error, we need to undo all the pages we've added. + const prev = chunk.page.prev; + errdefer { + var current = chunk.page.prev; + while (current) |p| { + if (current == prev) break; + current = p.prev; + self.pages.remove(p); + self.destroyPage(p); + } + } + + // We need to loop because our col growth may force us + // to split pages. + var copied: usize = 0; + while (copied < page.size.rows) { + const new_page = try self.createPage(cap); + const len = @min(cap.rows, page.size.rows - copied); + copied += len; + new_page.data.size.rows = len; + try new_page.data.cloneFrom(page, 0, len); + self.pages.insertBefore(chunk.page, new_page); + } + assert(copied == page.size.rows); + // Remove the old page. // Deallocate the old page. - self.pages.insertBefore(chunk.page, new_page); self.pages.remove(chunk.page); self.destroyPage(chunk.page); } @@ -2180,25 +2198,42 @@ test "PageList resize (no reflow) empty screen" { } } -// test "PageList bug" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s = try init(alloc, 300, 100, null); -// defer s.deinit(); -// try testing.expect(s.pages.first == s.pages.last); -// const page = &s.pages.first.?.data; -// for (0..s.rows) |y| { -// for (0..s.cols) |x| { -// const rac = page.getRowAndCell(x, y); -// rac.cell.* = .{ -// .content_tag = .codepoint, -// .content = .{ .codepoint = 'A' }, -// }; -// } -// } -// -// // Resize -// try s.resize(.{ .cols = s.cols * 2, .rows = s.rows * 2, .reflow = false }); -// try s.resize(.{ .cols = s.cols / 2, .rows = s.rows / 2, .reflow = false }); -// } +test "PageList resize (no reflow) more cols forces smaller cap" { + const testing = std.testing; + const alloc = testing.allocator; + + // We want a cap that forces us to have less rows + const cap = try std_capacity.adjust(.{ .cols = 100 }); + const cap2 = try std_capacity.adjust(.{ .cols = 500 }); + try testing.expectEqual(@as(size.CellCountInt, 500), cap2.cols); + try testing.expect(cap2.rows < cap.rows); + + // Create initial cap, fits in one page + var s = try init(alloc, cap.cols, cap.rows, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize to our large cap + const rows = s.totalRows(); + try s.resize(.{ .cols = cap2.cols, .reflow = false }); + + // Our total rows should be the same, and contents should be the same. + try testing.expectEqual(rows, s.totalRows()); + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, cap2.cols), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } +} From 324d7851475ff309718e9254726ab28c4812f4ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 1 Mar 2024 21:37:00 -0800 Subject: [PATCH 141/428] terminal/new: pagelist resize with reflow more cols with no wrapped rows --- src/terminal/new/PageList.zig | 278 ++++++++++++++++++++++------------ src/terminal/new/Screen.zig | 22 +++ 2 files changed, 205 insertions(+), 95 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index d70bc88004..06caf3a586 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -363,73 +363,54 @@ pub const Resize = struct { /// TODO: docs pub fn resize(self: *PageList, opts: Resize) !void { if (!opts.reflow) return try self.resizeWithoutReflow(opts); - @panic("TODO: resize with text reflow"); -} -/// Returns the number of trailing blank lines, not to exceed max. Max -/// is used to limit our traversal in the case of large scrollback. -fn trailingBlankLines( - self: *const PageList, - max: size.CellCountInt, -) size.CellCountInt { - var count: size.CellCountInt = 0; - - // Go through our pages backwards since we're counting trailing blanks. - var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows = page.data.rows.ptr(page.data.memory)[0..len]; - for (0..len) |i| { - const rev_i = len - i - 1; - const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; - - // If the row has any text then we're done. - if (pagepkg.Cell.hasTextAny(cells)) return count; + // On reflow, the main thing that causes reflow is column changes. If + // only rows change, reflow is impossible. So we change our behavior based + // on the change of columns. + const cols = opts.cols orelse self.cols; + switch (std.math.order(cols, self.cols)) { + .eq => try self.resizeWithoutReflow(opts), + + .gt => { + // We grow rows after cols so that we can do our unwrapping/reflow + // before we do a no-reflow grow. + try self.resizeGrowCols(cols); + try self.resizeWithoutReflow(opts); + }, - // Inc count, if we're beyond max then we're done. - count += 1; - if (count >= max) return count; - } + .lt => @panic("TODO"), } - - return count; } -/// Trims up to max trailing blank rows from the pagelist and returns the -/// number of rows trimmed. A blank row is any row with no text (but may -/// have styling). -fn trimTrailingBlankRows( - self: *PageList, - max: size.CellCountInt, -) size.CellCountInt { - var trimmed: size.CellCountInt = 0; - var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows_slice = page.data.rows.ptr(page.data.memory)[0..len]; - for (0..len) |i| { - const rev_i = len - i - 1; - const row = &rows_slice[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..page.data.size.cols]; +/// Resize the pagelist with reflow by adding columns. +fn resizeGrowCols(self: *PageList, cols: size.CellCountInt) !void { + assert(cols > self.cols); - // If the row has any text then we're done. - if (pagepkg.Cell.hasTextAny(cells)) return trimmed; + // Our new capacity, ensure we can grow to it. + const cap = try std_capacity.adjust(.{ .cols = cols }); - // No text, we can trim this row. Because it has - // no text we can also be sure it has no styling - // so we don't need to worry about memory. - page.data.size.rows -= 1; - trimmed += 1; - if (trimmed >= max) return trimmed; + // Go page by page and grow the columns on a per-page basis. + var it = self.pageIterator(.{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.page.data; + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + + // Fast-path: none of our rows are wrapped. In this case we can + // treat this like a no-reflow resize. + const wrapped = wrapped: for (rows) |row| { + assert(!row.wrap_continuation); // TODO + if (row.wrap) break :wrapped true; + } else false; + if (!wrapped) { + try self.resizeWithoutReflowGrowCols(cap, chunk); + continue; } - } - return trimmed; + @panic("TODO: wrapped"); + } } fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { - assert(!opts.reflow); - if (opts.rows) |rows| { switch (std.math.order(rows, self.rows)) { .eq => {}, @@ -506,53 +487,128 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.page.data; + try self.resizeWithoutReflowGrowCols(cap, chunk); + } - // Unlikely fast path: we have capacity in the page. This - // is only true if we resized to less cols earlier. - if (page.capacity.cols >= cols) { - page.size.cols = cols; - continue; - } + self.cols = cols; + }, + } + } +} - // Likely slow path: we don't have capacity, so we need - // to allocate a page, and copy the old data into it. - - // On error, we need to undo all the pages we've added. - const prev = chunk.page.prev; - errdefer { - var current = chunk.page.prev; - while (current) |p| { - if (current == prev) break; - current = p.prev; - self.pages.remove(p); - self.destroyPage(p); - } - } +fn resizeWithoutReflowGrowCols( + self: *PageList, + cap: Capacity, + chunk: PageIterator.Chunk, +) !void { + assert(cap.cols > self.cols); + const page = &chunk.page.data; + + // Update our col count + const old_cols = self.cols; + self.cols = cap.cols; + errdefer self.cols = old_cols; + + // Unlikely fast path: we have capacity in the page. This + // is only true if we resized to less cols earlier. + if (page.capacity.cols >= cap.cols) { + page.size.cols = cap.cols; + return; + } + + // Likely slow path: we don't have capacity, so we need + // to allocate a page, and copy the old data into it. + + // On error, we need to undo all the pages we've added. + const prev = chunk.page.prev; + errdefer { + var current = chunk.page.prev; + while (current) |p| { + if (current == prev) break; + current = p.prev; + self.pages.remove(p); + self.destroyPage(p); + } + } - // We need to loop because our col growth may force us - // to split pages. - var copied: usize = 0; - while (copied < page.size.rows) { - const new_page = try self.createPage(cap); - const len = @min(cap.rows, page.size.rows - copied); - copied += len; - new_page.data.size.rows = len; - try new_page.data.cloneFrom(page, 0, len); - self.pages.insertBefore(chunk.page, new_page); - } - assert(copied == page.size.rows); + // We need to loop because our col growth may force us + // to split pages. + var copied: usize = 0; + while (copied < page.size.rows) { + const new_page = try self.createPage(cap); + const len = @min(cap.rows, page.size.rows - copied); + copied += len; + new_page.data.size.rows = len; + try new_page.data.cloneFrom(page, 0, len); + self.pages.insertBefore(chunk.page, new_page); + } + assert(copied == page.size.rows); - // Remove the old page. - // Deallocate the old page. - self.pages.remove(chunk.page); - self.destroyPage(chunk.page); - } + // Remove the old page. + // Deallocate the old page. + self.pages.remove(chunk.page); + self.destroyPage(chunk.page); +} - self.cols = cols; - }, +/// Returns the number of trailing blank lines, not to exceed max. Max +/// is used to limit our traversal in the case of large scrollback. +fn trailingBlankLines( + self: *const PageList, + max: size.CellCountInt, +) size.CellCountInt { + var count: size.CellCountInt = 0; + + // Go through our pages backwards since we're counting trailing blanks. + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + const len = page.data.size.rows; + const rows = page.data.rows.ptr(page.data.memory)[0..len]; + for (0..len) |i| { + const rev_i = len - i - 1; + const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return count; + + // Inc count, if we're beyond max then we're done. + count += 1; + if (count >= max) return count; } } + + return count; +} + +/// Trims up to max trailing blank rows from the pagelist and returns the +/// number of rows trimmed. A blank row is any row with no text (but may +/// have styling). +fn trimTrailingBlankRows( + self: *PageList, + max: size.CellCountInt, +) size.CellCountInt { + var trimmed: size.CellCountInt = 0; + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + const len = page.data.size.rows; + const rows_slice = page.data.rows.ptr(page.data.memory)[0..len]; + for (0..len) |i| { + const rev_i = len - i - 1; + const row = &rows_slice[rev_i]; + const cells = row.cells.ptr(page.data.memory)[0..page.data.size.cols]; + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return trimmed; + + // No text, we can trim this row. Because it has + // no text we can also be sure it has no styling + // so we don't need to worry about memory. + page.data.size.rows -= 1; + trimmed += 1; + if (trimmed >= max) return trimmed; + } + } + + return trimmed; } /// Scroll options. @@ -2237,3 +2293,35 @@ test "PageList resize (no reflow) more cols forces smaller cap" { try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } } + +test "PageList resize reflow more cols no wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 10, .reflow = true }); + try testing.expectEqual(@as(usize, 10), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 10), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index ca9f003910..8a54cdf93c 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -569,6 +569,14 @@ fn blankCell(self: *const Screen) Cell { /// 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, @@ -588,6 +596,20 @@ pub fn resize( return; } + // No matter what we mark our image state as dirty + self.kitty_images.dirty = true; + + // We grow rows after cols so that we can do our unwrapping/reflow + // before we do a no-reflow grow. + // + // 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.pages.rows) { + try self.resizeWithoutReflow(rows, self.cols); + assert(self.pages.rows == rows); + } + @panic("TODO"); } From 23d850918866917182661cac109f8a7b1bf10ed0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Mar 2024 09:56:32 -0800 Subject: [PATCH 142/428] terminal/new: first grow cols reflow work, not done --- src/terminal/new/PageList.zig | 242 +++++++++++++++++++++++++++++++++- src/terminal/new/page.zig | 4 + 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 06caf3a586..ea31a3e8a5 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -406,8 +406,195 @@ fn resizeGrowCols(self: *PageList, cols: size.CellCountInt) !void { continue; } - @panic("TODO: wrapped"); + // Slow path, we have a wrapped row. We need to reflow the text. + // This is painful because we basically need to rewrite the entire + // page sequentially. + try self.reflowPage(cap, chunk.page); + } + + // If our total rows is less than our active rows, we need to grow. + // This can happen if you're growing columns such that enough active + // rows unwrap that we no longer have enough. + var node_it = self.pages.first; + var total: usize = 0; + while (node_it) |node| : (node_it = node.next) { + total += node.data.size.rows; + if (total >= self.rows) break; + } else { + for (total..self.rows) |_| _ = try self.grow(); + } +} + +// We use a cursor to track where we are in the src/dst. This is very +// similar to Screen.Cursor, so see that for docs on individual fields. +// We don't use a Screen because we don't need all the same data and we +// do our best to optimize having direct access to the page memory. +const ReflowCursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + pending_wrap: bool, + page: *pagepkg.Page, + page_row: *pagepkg.Row, + page_cell: *pagepkg.Cell, + + fn init(page: *pagepkg.Page) ReflowCursor { + const rows = page.rows.ptr(page.memory); + return .{ + .x = 0, + .y = 0, + .pending_wrap = false, + .page = page, + .page_row = &rows[0], + .page_cell = &rows[0].cells.ptr(page.memory)[0], + }; + } + + fn cursorForward(self: *ReflowCursor) void { + if (self.x == self.page.size.cols - 1) { + self.pending_wrap = true; + } else { + const cell: [*]pagepkg.Cell = @ptrCast(self.page_cell); + self.page_cell = @ptrCast(cell + 1); + self.x += 1; + } + } + + fn cursorScroll(self: *ReflowCursor) void { + // Scrolling requires that we're on the bottom of our page. + // We also assert that we have capacity because reflow always + // works within the capacity of the page. + assert(self.y == self.page.size.rows - 1); + assert(self.page.size.rows < self.page.capacity.rows); + + // Increase our page size + self.page.size.rows += 1; + + // With the increased page size, safely move down a row. + const rows: [*]pagepkg.Row = @ptrCast(self.page_row); + const row: *pagepkg.Row = @ptrCast(rows + 1); + self.page_row = row; + self.page_cell = &row.cells.ptr(self.page.memory)[0]; + self.pending_wrap = false; + self.x = 0; + self.y += 1; + } + + fn cursorAbsolute( + self: *ReflowCursor, + x: size.CellCountInt, + y: size.CellCountInt, + ) void { + assert(x < self.page.size.cols); + assert(y < self.page.size.rows); + + const rows: [*]pagepkg.Row = @ptrCast(self.page_row); + const row: *pagepkg.Row = switch (std.math.order(y, self.y)) { + .eq => self.page_row, + .lt => @ptrCast(rows - (self.y - y)), + .gt => @ptrCast(rows + (y - self.y)), + }; + self.page_row = row; + self.page_cell = &row.cells.ptr(self.page.memory)[x]; + self.pending_wrap = false; + self.x = x; + self.y = y; + } +}; + +/// Reflow the given page into the new capacity. The new capacity can have +/// any number of columns and rows. This will create as many pages as +/// necessary to fit the reflowed text and will remove the old page. +/// +/// Note a couple edge cases: +/// +/// 1. If the first set of rows of this page are a wrap continuation, then +/// we will reflow the continuation rows but will not traverse back to +/// find the initial wrap. +/// +/// 2. If the last row is wrapped then we will traverse forward to reflow +/// all the continuation rows. +/// +/// As a result of the above edge cases, the pagelist may end up removing +/// an indefinite number of pages. In the most pathological cases (the screen +/// is one giant wrapped line), this can be a very expensive operation. That +/// doesn't really happen in typical terminal usage so its not a case we +/// optimize for today. Contributions welcome to optimize this. +fn reflowPage( + self: *PageList, + cap: Capacity, + node: *List.Node, +) !void { + assert(cap.cols > self.cols); + + // The cursor tracks where we are in the source page. + var src_cursor = ReflowCursor.init(&node.data); + + // Our new capacity when growing columns may also shrink rows. So we + // need to do a loop in order to potentially make multiple pages. + while (true) { + // Create our new page and our cursor restarts at 0,0 in the new page. + // The new page always starts with a size of 1 because we know we have + // at least one row to copy from the src. + const dst_node = try self.createPage(cap); + dst_node.data.size.rows = 1; + var dst_cursor = ReflowCursor.init(&dst_node.data); + + // Our new page goes before our src node. This will append it to any + // previous pages we've created. + self.pages.insertBefore(node, dst_node); + + // Continue traversing the source until we're out of space in our + // destination or we've copied all our intended rows. + for (src_cursor.y..src_cursor.page.size.rows) |src_y| { + if (src_y > 0) { + // We're done with this row, if this row isn't wrapped, we can + // move our destination cursor to the next row. + if (!src_cursor.page_row.wrap) { + dst_cursor.cursorScroll(); + } + } + + src_cursor.cursorAbsolute(src_cursor.x, @intCast(src_y)); + + for (src_cursor.x..src_cursor.page.size.cols) |src_x| { + assert(src_cursor.x == src_x); + + if (dst_cursor.pending_wrap) { + @panic("TODO"); + } + + switch (src_cursor.page_cell.content_tag) { + // These are guaranteed to have no styling data and no + // graphemes, a fast path. + .bg_color_palette, + .bg_color_rgb, + => { + assert(!src_cursor.page_cell.hasStyling()); + assert(!src_cursor.page_cell.hasGrapheme()); + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint => { + dst_cursor.page_cell.* = src_cursor.page_cell.*; + // TODO: style copy + }, + + else => @panic("TODO"), + } + + // Move both our cursors forward + src_cursor.cursorForward(); + dst_cursor.cursorForward(); + } + } else { + // We made it through all our source rows, we're done. + break; + } } + + // Finally, remove the old page. + self.pages.remove(node); + self.destroyPage(node); } fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { @@ -2325,3 +2512,56 @@ test "PageList resize reflow more cols no wrapped rows" { try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } } + +test "PageList resize reflow more cols wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + if (y % 2 == 0) { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } else { + const rac = page.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active should still be on top + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + var it = s.rowIterator(.{ .screen = .{} }, null); + { + // First row should be unwrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cells[2].content.codepoint); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index ac037d5d7e..28c7402126 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -767,6 +767,10 @@ pub const Cell = packed struct(u64) { }; } + pub fn hasStyling(self: Cell) bool { + return self.style_id != style.default_id; + } + /// Returns true if the cell has no text or styling. pub fn isEmpty(self: Cell) bool { return switch (self.content_tag) { From d71657ded1c4fcafac0554e2505a1e66ffb5f1c3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Mar 2024 10:03:28 -0800 Subject: [PATCH 143/428] terminal/new: start porting resize tests, bugs --- src/terminal/Screen.zig | 3 + src/terminal/new/Screen.zig | 118 ++++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 20c9dae008..814e42080c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6353,6 +6353,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { } } +// X test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -6379,6 +6380,7 @@ test "Screen: resize more rows no scrollback" { } } +// X test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -6405,6 +6407,7 @@ test "Screen: resize more rows with empty scrollback" { } } +// X test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 8a54cdf93c..c894fdebd5 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -599,18 +599,14 @@ pub fn resize( // No matter what we mark our image state as dirty self.kitty_images.dirty = true; - // We grow rows after cols so that we can do our unwrapping/reflow - // before we do a no-reflow grow. - // - // 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.pages.rows) { - try self.resizeWithoutReflow(rows, self.cols); - assert(self.pages.rows == rows); - } + // Resize our pages + try self.pages.resize(.{ + .rows = rows, + .cols = cols, + .reflow = true, + }); - @panic("TODO"); + // TODO: cursor } /// Resize the screen without any reflow. In this mode, columns/rows will @@ -2060,6 +2056,106 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { } } +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" + // TODO + // { + // 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; From 43629870d568dfdb0f8562b17bcad09974cd7a7e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Mar 2024 21:33:05 -0800 Subject: [PATCH 144/428] terminal/new: resize without reflow updates cursor --- src/terminal/new/PageList.zig | 218 ++++++++++++++++++++++++++++++++-- src/terminal/new/Screen.zig | 51 +++++--- 2 files changed, 242 insertions(+), 27 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index ea31a3e8a5..6c6b89f0f8 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -357,6 +357,16 @@ pub const Resize = struct { /// Whether to reflow the text. If this is false then the text will /// be truncated if the new size is smaller than the old size. reflow: bool = true, + + /// Set this to a cursor position and the resize will retain the + /// cursor position and update this so that the cursor remains over + /// the same original cell in the reflowed environment. + cursor: ?*Cursor = null, + + pub const Cursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + }; }; /// Resize @@ -614,7 +624,15 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // 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. - _ = self.trimTrailingBlankRows(self.rows - rows); + const trimmed = self.trimTrailingBlankRows(self.rows - rows); + + // If we have a cursor, we want to preserve the y value as + // best we can. We need to subtract the number of rows that + // moved into the scrollback. + if (opts.cursor) |cursor| { + const scrollback = self.rows - rows - trimmed; + cursor.y -|= scrollback; + } // If we didn't trim enough, just modify our row count and this // will create additional history. @@ -624,20 +642,45 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // Making rows larger we adjust our row count, and then grow // to the row count. .gt => gt: { - self.rows = rows; + // 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 (opts.cursor) |cursor| cursor: { + if (cursor.y >= self.rows - 1) break :cursor; + + // Cursor is not at the bottom, so we just grow our + // rows and we're done. Cursor does NOT change for this + // since we're not pulling down scrollback. + for (0..rows - self.rows) |_| _ = try self.grow(); + self.rows = rows; + break :gt; + } - // Perform a quick count to make sure we have at least - // the number of rows we need. This should be fast because - // we only need to count up to "rows" + // Cursor is at the bottom or we don't care about cursors. + // In this case, if we have enough rows in our pages, we + // just update our rows and we're done. This effectively + // "pulls down" scrollback. + // + // If we don't have enough scrollback, we add the difference, + // to the active area. var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { count += p.data.size.rows; - if (count >= rows) break :gt; + if (count >= rows) break; + } else { + assert(count < rows); + for (count..rows) |_| _ = try self.grow(); } - assert(count < rows); - for (count..rows) |_| _ = try self.grow(); + // Update our cursor. W + if (opts.cursor) |cursor| { + const grow_len: size.CellCountInt = @intCast(rows -| count); + cursor.y += rows - self.rows - grow_len; + } + + self.rows = rows; }, } } @@ -663,6 +706,11 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { page.size.cols = cols; } + if (opts.cursor) |cursor| { + // If our cursor is off the edge we trimmed, update to edge + if (cursor.x >= cols) cursor.x = cols - 1; + } + self.cols = cols; }, @@ -2129,11 +2177,19 @@ test "PageList resize (no reflow) more rows" { defer s.deinit(); try testing.expectEqual(@as(usize, 3), s.totalRows()); + // Cursor is at the bottom + var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + // Resize - try s.resize(.{ .rows = 10, .reflow = false }); + try s.resize(.{ .rows = 10, .reflow = false, .cursor = &cursor }); try testing.expectEqual(@as(usize, 10), s.rows); try testing.expectEqual(@as(usize, 10), s.totalRows()); + // Our cursor should not move because we have no scrollback so + // we just grew. + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), cursor.y); + { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -2158,10 +2214,18 @@ test "PageList resize (no reflow) more rows with history" { } }, pt); } + // Cursor is at the bottom + var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + // Resize - try s.resize(.{ .rows = 5, .reflow = false }); + try s.resize(.{ .rows = 5, .reflow = false, .cursor = &cursor }); try testing.expectEqual(@as(usize, 5), s.rows); try testing.expectEqual(@as(usize, 53), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 4), cursor.y); + { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -2205,6 +2269,55 @@ test "PageList resize (no reflow) less rows" { } } +test "PageList resize (no reflow) less rows cursor in scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Let's say our cursor is in the scrollback + var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + { + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 2), get.cell.content.codepoint); + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} + test "PageList resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; @@ -2232,10 +2345,25 @@ test "PageList resize (no reflow) less rows trims blank lines" { }; } + // Let's say our cursor is at the top + var cursor: Resize.Cursor = .{ .x = 0, .y = 0 }; + { + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 'A'), get.cell.content.codepoint); + } + // Resize - try s.resize(.{ .rows = 2, .reflow = false }); + try s.resize(.{ .rows = 2, .reflow = false, .cursor = &cursor }); try testing.expectEqual(@as(usize, 2), s.rows); try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should not move since we trimmed + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -2481,6 +2609,74 @@ test "PageList resize (no reflow) more cols forces smaller cap" { } } +test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + + // Grow to 5 total rows, simulating 3 active + 2 scrollback + try s.growRows(2); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.totalRows()) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Active should be on row 3 + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + // Let's say our cursor is at the bottom + var cursor: Resize.Cursor = .{ .x = 0, .y = s.rows - 2 }; + { + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 3), get.cell.content.codepoint); + } + + // Resize + const original_cursor = cursor; + try s.resizeWithoutReflow(.{ .rows = 10, .reflow = false, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.rows); + + // Our cursor should not change + try testing.expectEqual(original_cursor, cursor); + + // 12 because we have our 10 rows in the active + 2 in the scrollback + // because we're preserving the cursor. + try testing.expectEqual(@as(usize, 12), s.totalRows()); + + // Active should be at the same place it was. + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + // Go through our active, we should get only 3,4,5 + for (0..3) |y| { + const get = s.getCell(.{ .active = .{ .y = y } }).?; + const expected: u21 = @intCast(y + 2); + try testing.expectEqual(expected, get.cell.content.codepoint); + } +} + test "PageList resize reflow more cols no wrapped rows" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index c894fdebd5..6197f0eb8d 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -617,7 +617,21 @@ pub fn resizeWithoutReflow( cols: size.CellCountInt, rows: size.CellCountInt, ) !void { - try self.pages.resize(.{ .rows = rows, .cols = cols, .reflow = false }); + var cursor: PageList.Resize.Cursor = .{ + .x = self.cursor.x, + .y = self.cursor.y, + }; + + try self.pages.resize(.{ + .rows = rows, + .cols = cols, + .reflow = false, + .cursor = &cursor, + }); + + self.cursor.x = cursor.x; + self.cursor.y = cursor.y; + self.cursorReload(); } /// Set a style attribute for the current cursor. @@ -1853,8 +1867,14 @@ test "Screen: resize (no reflow) less rows" { 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); @@ -2139,21 +2159,20 @@ test "Screen: resize more rows with populated scrollback" { try s.resize(5, 10); // Cursor should still be on the "4" - // TODO - // { - // 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); - // } + { + 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" { From 839fae55f450ae2e1d7a25e18ab6ed4b194a6bcc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Mar 2024 21:40:26 -0800 Subject: [PATCH 145/428] terminal/new: port more screen resize tests --- src/terminal/Screen.zig | 3 + src/terminal/new/Screen.zig | 120 ++++++++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 814e42080c..694698bc1d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6475,6 +6475,7 @@ test "Screen: resize more rows and cols with wrapping" { } } +// X test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator; @@ -6501,6 +6502,7 @@ test "Screen: resize more cols no reflow" { } } +// X // https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 test "Screen: resize more cols perfect split" { const testing = std.testing; @@ -6513,6 +6515,7 @@ test "Screen: resize more cols perfect split" { try s.resize(3, 10); } +// X // https://github.com/mitchellh/ghostty/issues/1159 test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 6197f0eb8d..3b8c352582 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -599,14 +599,23 @@ pub fn resize( // No matter what we mark our image state as dirty self.kitty_images.dirty = true; - // Resize our pages + var cursor: PageList.Resize.Cursor = .{ + .x = self.cursor.x, + .y = self.cursor.y, + }; + try self.pages.resize(.{ .rows = rows, .cols = cols, .reflow = true, + .cursor = &cursor, }); - // TODO: cursor + if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { + self.cursor.x = cursor.x; + self.cursor.y = cursor.y; + self.cursorReload(); + } } /// Resize the screen without any reflow. In this mode, columns/rows will @@ -629,9 +638,11 @@ pub fn resizeWithoutReflow( .cursor = &cursor, }); - self.cursor.x = cursor.x; - self.cursor.y = cursor.y; - self.cursorReload(); + if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { + self.cursor.x = cursor.x; + self.cursor.y = cursor.y; + self.cursorReload(); + } } /// Set a style attribute for the current cursor. @@ -2175,30 +2186,75 @@ test "Screen: resize more rows with populated scrollback" { } } -// 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); -// } -// } +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); +} + +// 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); +} From 7b70dd133805e663dbe7747d90fa98751f26d3c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Mar 2024 21:56:36 -0800 Subject: [PATCH 146/428] terminal/new: more resize more cols tests --- src/terminal/Screen.zig | 7 ++++++ src/terminal/new/PageList.zig | 7 +++--- src/terminal/new/Screen.zig | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 694698bc1d..1aff79a0c8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6513,6 +6513,12 @@ test "Screen: resize more cols perfect split" { 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 @@ -6669,6 +6675,7 @@ test "Screen: resize more cols grapheme map" { } } +// X test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 6c6b89f0f8..5f0c6e0110 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -564,13 +564,14 @@ fn reflowPage( } } - src_cursor.cursorAbsolute(src_cursor.x, @intCast(src_y)); - + src_cursor.cursorAbsolute(0, @intCast(src_y)); for (src_cursor.x..src_cursor.page.size.cols) |src_x| { assert(src_cursor.x == src_x); if (dst_cursor.pending_wrap) { - @panic("TODO"); + dst_cursor.page_row.wrap = true; + dst_cursor.cursorScroll(); + dst_cursor.page_row.wrap_continuation = true; } switch (src_cursor.page_cell.content_tag) { diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 3b8c352582..40ba84b2fc 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2224,6 +2224,12 @@ test "Screen: resize more cols perfect split" { 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 @@ -2258,3 +2264,44 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } + +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 + // TODO + // try testing.expectEqual(@as(usize, 5), s.cursor.x); + // try testing.expectEqual(@as(usize, 0), s.cursor.y); +} From b92d5cdb582bdb67d7f46f8af9acd9717025bde3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 09:03:08 -0800 Subject: [PATCH 147/428] terminal/new: recalculate cursor on more cols reflow --- src/terminal/new/PageList.zig | 228 +++++++++++++++++++++++++++++++--- src/terminal/new/Screen.zig | 5 +- 2 files changed, 215 insertions(+), 18 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 5f0c6e0110..a6a980de8d 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -366,6 +366,12 @@ pub const Resize = struct { pub const Cursor = struct { x: size.CellCountInt, y: size.CellCountInt, + + /// The row offset of the cursor. This is assumed to be correct + /// if set. If this is not set, then the row offset will be + /// calculated from the x/y. Calculating the row offset is expensive + /// so if you have it, you should set it. + offset: ?RowOffset = null, }; }; @@ -384,7 +390,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { .gt => { // We grow rows after cols so that we can do our unwrapping/reflow // before we do a no-reflow grow. - try self.resizeGrowCols(cols); + try self.resizeGrowCols(cols, opts.cursor); try self.resizeWithoutReflow(opts); }, @@ -393,12 +399,30 @@ pub fn resize(self: *PageList, opts: Resize) !void { } /// Resize the pagelist with reflow by adding columns. -fn resizeGrowCols(self: *PageList, cols: size.CellCountInt) !void { +fn resizeGrowCols( + self: *PageList, + cols: size.CellCountInt, + cursor: ?*Resize.Cursor, +) !void { assert(cols > self.cols); // Our new capacity, ensure we can grow to it. const cap = try std_capacity.adjust(.{ .cols = cols }); + // If we are given a cursor, we need to calculate the row offset. + if (cursor) |c| { + if (c.offset == null) { + const tl = self.getTopLeft(.active); + c.offset = tl.forward(c.y) orelse fail: { + // This should never happen, but its not critical enough to + // set an assertion and fail the program. The caller should ALWAYS + // input a valid x/y.. + log.err("cursor offset not found, resize will set wrong cursor", .{}); + break :fail null; + }; + } + } + // Go page by page and grow the columns on a per-page basis. var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { @@ -419,7 +443,7 @@ fn resizeGrowCols(self: *PageList, cols: size.CellCountInt) !void { // Slow path, we have a wrapped row. We need to reflow the text. // This is painful because we basically need to rewrite the entire // page sequentially. - try self.reflowPage(cap, chunk.page); + try self.reflowPage(cap, chunk.page, cursor); } // If our total rows is less than our active rows, we need to grow. @@ -533,6 +557,7 @@ fn reflowPage( self: *PageList, cap: Capacity, node: *List.Node, + cursor: ?*Resize.Cursor, ) !void { assert(cap.cols > self.cols); @@ -593,6 +618,32 @@ fn reflowPage( else => @panic("TODO"), } + // If our original cursor was on this page, this x/y then + // we need to update to the new location. + if (cursor) |c| cursor: { + const offset = c.offset orelse break :cursor; + if (&offset.page.data == src_cursor.page and + offset.row_offset == src_cursor.y and + c.x == src_cursor.x) + { + // Column always matches our dst x + c.x = dst_cursor.x; + + // Our y is more complicated. The cursor y is the active + // area y, not the row offset. Our cursors are row offsets. + // Instead of calculating the active area coord, we can + // better calculate the CHANGE in coordinate by subtracting + // our dst from src which will calculate how many rows + // we unwrapped to get here. + c.y -= src_cursor.y - dst_cursor.y; + + c.offset = .{ + .page = dst_node, + .row_offset = dst_cursor.y, + }; + } + } + // Move both our cursors forward src_cursor.cursorForward(); dst_cursor.cursorForward(); @@ -1124,18 +1175,7 @@ fn erasePage(self: *PageList, page: *List.Node) void { /// Get the top-left of the screen for the given tag. pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { // TODO: assert the point is valid - - // This should never return null because we assert the point is valid. - return (switch (pt) { - .active => |v| self.active.forward(v.y), - .viewport => |v| switch (self.viewport) { - .active => self.active.forward(v.y), - }, - .screen, .history => |v| offset: { - const tl: RowOffset = .{ .page = self.pages.first.? }; - break :offset tl.forward(v.y); - }, - }).?; + return self.getTopLeft(pt).forward(pt.coord().y).?; } /// Get the cell at the given point, or null if the cell does not @@ -2762,3 +2802,161 @@ test "PageList resize reflow more cols wrapped rows" { try testing.expectEqual(@as(u21, 'A'), cells[2].content.codepoint); } } + +test "PageList resize reflow more cols cursor in wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 1, .y = 1 }; + + // Resize + try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 3), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} + +test "PageList resize reflow more cols cursor in not wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + + // Resize + try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 2), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} + +test "PageList resize reflow more cols cursor in wrapped row that isn't unwrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap = true; + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + { + const rac = page.getRowAndCell(0, 2); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 1, .y = 2 }; + + // Resize + try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 40ba84b2fc..5877b076b0 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2301,7 +2301,6 @@ test "Screen: resize more cols with reflow that fits full width" { } // Our cursor should've moved - // TODO - // try testing.expectEqual(@as(usize, 5), s.cursor.x); - // try testing.expectEqual(@as(usize, 0), s.cursor.y); + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 0), s.cursor.y); } From f1887e7b1b38015c92c85ad0b6f101390dffa5bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 09:18:34 -0800 Subject: [PATCH 148/428] terminal/new: resize more cols ignores trailing empty cells --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 33 +++++++++++++++++++++++-- src/terminal/new/Screen.zig | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1aff79a0c8..e892f9e253 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6711,6 +6711,7 @@ test "Screen: resize more cols with reflow that fits full width" { 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; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index a6a980de8d..784a87ff74 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -533,6 +533,20 @@ const ReflowCursor = struct { self.x = x; self.y = y; } + + fn countTrailingEmptyCells(self: *const ReflowCursor) usize { + // If the row is wrapped, all empty cells are meaningful. + if (self.page_row.wrap) return 0; + + const cells: [*]pagepkg.Cell = @ptrCast(self.page_cell); + const len: usize = self.page.size.cols - self.x; + for (0..len) |i| { + const rev_i = len - i - 1; + if (!cells[rev_i].isEmpty()) return i; + } + + return len; + } }; /// Reflow the given page into the new capacity. The new capacity can have @@ -590,9 +604,24 @@ fn reflowPage( } src_cursor.cursorAbsolute(0, @intCast(src_y)); - for (src_cursor.x..src_cursor.page.size.cols) |src_x| { + + // Trim trailing empty cells if the row is not wrapped. If the + // row is wrapped then we don't trim trailing empty cells because + // the empty cells can be meaningful. + const trailing_empty = src_cursor.countTrailingEmptyCells(); + const cols_len = src_cursor.page.size.cols - trailing_empty; + + for (src_cursor.x..cols_len) |src_x| { assert(src_cursor.x == src_x); + // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} cp={u}", .{ + // src_cursor.y, + // src_cursor.x, + // dst_cursor.y, + // dst_cursor.x, + // src_cursor.page_cell.content.codepoint, + // }); + if (dst_cursor.pending_wrap) { dst_cursor.page_row.wrap = true; dst_cursor.cursorScroll(); @@ -2940,7 +2969,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe rac.row.wrap_continuation = true; } for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, 1); + const rac = page.getRowAndCell(x, 2); rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = @intCast(x) }, diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 5877b076b0..c198405223 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2304,3 +2304,48 @@ test "Screen: resize more cols with reflow that fits full width" { 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); + } +} From fad08ade5b14047f11501eb127b21aabdddbc192 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 09:40:56 -0800 Subject: [PATCH 149/428] terminal/new: lots more tests ported --- src/terminal/Screen.zig | 9 + src/terminal/new/PageList.zig | 10 +- src/terminal/new/Screen.zig | 343 ++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e892f9e253..4d3d114246 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6746,6 +6746,7 @@ test "Screen: resize more cols with reflow that ends in newline" { 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; @@ -6782,6 +6783,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { 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; @@ -6818,6 +6820,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { 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; @@ -6852,6 +6855,7 @@ test "Screen: resize more cols with populated scrollback" { } } +// X test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; @@ -6889,6 +6893,7 @@ test "Screen: resize more cols with reflow" { 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; @@ -6919,6 +6924,7 @@ test "Screen: resize less rows no scrollback" { } } +// X test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator; @@ -6954,6 +6960,7 @@ test "Screen: resize less rows moving cursor" { } } +// X test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -6977,6 +6984,7 @@ test "Screen: resize less rows with empty scrollback" { } } +// X test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7008,6 +7016,7 @@ test "Screen: resize less rows with populated scrollback" { } } +// X test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 784a87ff74..72e4726618 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -655,6 +655,14 @@ fn reflowPage( offset.row_offset == src_cursor.y and c.x == src_cursor.x) { + // std.log.warn("c.x={} c.y={} dst_x={} dst_y={} src_y={}", .{ + // c.x, + // c.y, + // dst_cursor.x, + // dst_cursor.y, + // src_cursor.y, + // }); + // Column always matches our dst x c.x = dst_cursor.x; @@ -664,7 +672,7 @@ fn reflowPage( // better calculate the CHANGE in coordinate by subtracting // our dst from src which will calculate how many rows // we unwrapped to get here. - c.y -= src_cursor.y - dst_cursor.y; + c.y -|= src_cursor.y - dst_cursor.y; c.offset = .{ .page = dst_node, diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index c198405223..b49c4853eb 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -631,6 +631,8 @@ pub fn resizeWithoutReflow( .y = self.cursor.y, }; + const old_rows = self.pages.rows; + try self.pages.resize(.{ .rows = rows, .cols = cols, @@ -638,6 +640,13 @@ pub fn resizeWithoutReflow( .cursor = &cursor, }); + // 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 and rows < old_rows) { + self.pages.eraseRows(.{ .history = .{} }, null); + } + if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { self.cursor.x = cursor.x; self.cursor.y = cursor.y; @@ -2349,3 +2358,337 @@ test "Screen: resize more cols with reflow that ends in newline" { 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" + // TODO + // { + // 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 + // TODO + // try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.x); + // try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); +} + +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); + } +} From 2147097631096efa8586851ce7166b8c1b866b40 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 17:16:50 -0800 Subject: [PATCH 150/428] terminal/new: fix up cursor on grow cols --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 43 ++++++++++++++++++++++++++++ src/terminal/new/Screen.zig | 53 +++++++++++++++++++++++++++-------- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4d3d114246..79672102e3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6442,6 +6442,7 @@ test "Screen: resize more rows with populated scrollback" { } } +// X test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 72e4726618..e7ebe8c05d 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -457,6 +457,46 @@ fn resizeGrowCols( } else { for (total..self.rows) |_| _ = try self.grow(); } + + // If we have a cursor, we need to update the correct y value. I'm + // not at all happy about this, I wish we could do this in a more + // efficient way as we resize the pages. But at the time of typing this + // I can't think of a way and I'd rather get things working. Someone please + // help! + // + // The challenge is that as rows are unwrapped, we want to preserve the + // cursor. So for examle if you have "A\nB" where AB is soft-wrapped and + // the cursor is on 'B' (x=0, y=1) and you grow the columns, we want + // the cursor to remain on B (x=1, y=0) as it grows. + // + // The easy thing to do would be to count how many rows we unwrapped + // and then subtract that from the original y. That's how I started. The + // challenge is that if we unwrap with scrollback, our scrollback is + // "pulled down" so that the original (x=0,y=0) line is now pushed down. + // Detecting this while resizing seems non-obvious. This is a tested case + // so if you change this logic, you should see failures or passes if it + // works. + // + // The approach I take instead is if we have a cursor offset, I work + // backwards to find the offset we marked while reflowing and update + // the y from that. This is _not terrible_ because active areas are + // generally small and this is a more or less linear search. Its just + // kind of clunky. + if (cursor) |c| cursor: { + const offset = c.offset orelse break :cursor; + var active_it = self.rowIterator(.{ .active = .{} }, null); + var y: size.CellCountInt = 0; + while (active_it.next()) |it_offset| { + if (it_offset.page == offset.page and + it_offset.row_offset == offset.row_offset) + { + c.y = y; + break :cursor; + } + + y += 1; + } + } } // We use a cursor to track where we are in the src/dst. This is very @@ -672,6 +712,9 @@ fn reflowPage( // better calculate the CHANGE in coordinate by subtracting // our dst from src which will calculate how many rows // we unwrapped to get here. + // + // Note this doesn't handle when we pull down scrollback. + // See the cursor updates in resizeGrowCols for that. c.y -|= src_cursor.y - dst_cursor.y; c.offset = .{ diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index b49c4853eb..226aeef5d4 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2476,14 +2476,13 @@ test "Screen: resize more cols with populated scrollback" { } // Cursor should still be on the "5" - // TODO - // { - // 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); - // } + { + 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" { @@ -2523,9 +2522,41 @@ test "Screen: resize more cols with reflow" { } // Our cursor should've moved - // TODO - // try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.x); - // try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + 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" { From 89be10bad58c467f3cf64325029f44a5b1682c55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 20:48:50 -0800 Subject: [PATCH 151/428] terminal/new: start reflow of less cols --- src/terminal/new/PageList.zig | 147 +++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 3 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index e7ebe8c05d..b3279b425a 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -394,10 +394,45 @@ pub fn resize(self: *PageList, opts: Resize) !void { try self.resizeWithoutReflow(opts); }, - .lt => @panic("TODO"), + .lt => { + // We first change our row count so that we have the proper amount + // we can use when shrinking our cols. + try self.resizeWithoutReflow(opts: { + var copy = opts; + copy.cols = self.cols; + break :opts copy; + }); + + try self.resizeShrinkCols(cols, opts.cursor); + }, } } +/// Resize the pagelist with reflow by removing columns. +fn resizeShrinkCols( + self: *PageList, + cols: size.CellCountInt, + cursor: ?*Resize.Cursor, +) !void { + assert(cols < self.cols); + + // Our new capacity, ensure we can shrink to it. + const cap = try std_capacity.adjust(.{ .cols = cols }); + + // Go page by page and shrink the columns on a per-page basis. + var it = self.pageIterator(.{ .screen = .{} }, null); + while (it.next()) |chunk| { + // Note: we can do a fast-path here if all of our rows in this + // page already fit within the new capacity. In that case we can + // do a non-reflow resize. + + try self.reflowPage(cap, chunk.page, cursor); + } + + // Update our cols + self.cols = cols; +} + /// Resize the pagelist with reflow by adding columns. fn resizeGrowCols( self: *PageList, @@ -497,6 +532,9 @@ fn resizeGrowCols( y += 1; } } + + // Update our cols + self.cols = cols; } // We use a cursor to track where we are in the src/dst. This is very @@ -613,8 +651,6 @@ fn reflowPage( node: *List.Node, cursor: ?*Resize.Cursor, ) !void { - assert(cap.cols > self.cols); - // The cursor tracks where we are in the source page. var src_cursor = ReflowCursor.init(&node.data); @@ -3040,3 +3076,108 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); } + +test "PageList resize reflow less cols no wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + const end = 4; + assert(end < s.cols); + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 5, .reflow = true }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + for (0..4) |x| { + const rac = offset.rowAndCell(x); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); + } + } +} + +test "PageList resize reflow less cols wrapped rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active moves due to scrollback + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + var it = s.rowIterator(.{ .screen = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} From b4119455fd9956a8752e7848ce40365dbb2d4406 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 20:56:22 -0800 Subject: [PATCH 152/428] terminal/new: less cols cursor tests --- src/terminal/new/PageList.zig | 130 +++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index b3279b425a..c233142ae5 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -419,16 +419,49 @@ fn resizeShrinkCols( // Our new capacity, ensure we can shrink to it. const cap = try std_capacity.adjust(.{ .cols = cols }); + // If we are given a cursor, we need to calculate the row offset. + if (cursor) |c| { + if (c.offset == null) { + const tl = self.getTopLeft(.active); + c.offset = tl.forward(c.y) orelse fail: { + // This should never happen, but its not critical enough to + // set an assertion and fail the program. The caller should ALWAYS + // input a valid x/y.. + log.err("cursor offset not found, resize will set wrong cursor", .{}); + break :fail null; + }; + } + } + // Go page by page and shrink the columns on a per-page basis. var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { // Note: we can do a fast-path here if all of our rows in this // page already fit within the new capacity. In that case we can // do a non-reflow resize. - try self.reflowPage(cap, chunk.page, cursor); } + if (cursor) |c| cursor: { + const offset = c.offset orelse break :cursor; + var active_it = self.rowIterator(.{ .active = .{} }, null); + var y: size.CellCountInt = 0; + while (active_it.next()) |it_offset| { + if (it_offset.page == offset.page and + it_offset.row_offset == offset.row_offset) + { + c.y = y; + break :cursor; + } + + y += 1; + } else { + // Cursor moved off the screen into the scrollback. + c.x = 0; + c.y = 0; + } + } + // Update our cols self.cols = cols; } @@ -751,7 +784,7 @@ fn reflowPage( // // Note this doesn't handle when we pull down scrollback. // See the cursor updates in resizeGrowCols for that. - c.y -|= src_cursor.y - dst_cursor.y; + //c.y -|= src_cursor.y - dst_cursor.y; c.offset = .{ .page = dst_node, @@ -3181,3 +3214,96 @@ test "PageList resize reflow less cols wrapped rows" { try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); } } + +test "PageList resize reflow less cols cursor in wrapped row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 2, .y = 1 }; + + // Resize + try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); +} + +test "PageList resize reflow less cols cursor goes to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + + // Resize + try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} + +test "PageList resize reflow less cols cursor in unchanged row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the wrapped row + var cursor: Resize.Cursor = .{ .x = 1, .y = 0 }; + + // Resize + try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} From 95fca1d72b4364dfe6b8a5ff9d1576830457611d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 21:20:21 -0800 Subject: [PATCH 153/428] terminal/new: handle blank lines in reflow --- src/terminal/Screen.zig | 2 + src/terminal/new/PageList.zig | 143 ++++++++++++++++++++++++++++++++-- src/terminal/new/Screen.zig | 67 ++++++++++++++++ 3 files changed, 204 insertions(+), 8 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 79672102e3..30dc0e809d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7057,6 +7057,7 @@ test "Screen: resize less rows with full scrollback" { } } +// X test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; @@ -7222,6 +7223,7 @@ test "Screen: resize less cols no reflow preserves semantic prompt" { } } +// X test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index c233142ae5..76dedfba01 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -442,6 +442,18 @@ fn resizeShrinkCols( try self.reflowPage(cap, chunk.page, cursor); } + // If our total rows is less than our active rows, we need to grow. + // This can happen if you're growing columns such that enough active + // rows unwrap that we no longer have enough. + var node_it = self.pages.first; + var total: usize = 0; + while (node_it) |node| : (node_it = node.next) { + total += node.data.size.rows; + if (total >= self.rows) break; + } else { + for (total..self.rows) |_| _ = try self.grow(); + } + if (cursor) |c| cursor: { const offset = c.offset orelse break :cursor; var active_it = self.rowIterator(.{ .active = .{} }, null); @@ -687,6 +699,9 @@ fn reflowPage( // The cursor tracks where we are in the source page. var src_cursor = ReflowCursor.init(&node.data); + // This is used to count blank lines so that we don't copy those. + var blank_lines: usize = 0; + // Our new capacity when growing columns may also shrink rows. So we // need to do a loop in order to potentially make multiple pages. while (true) { @@ -704,14 +719,7 @@ fn reflowPage( // Continue traversing the source until we're out of space in our // destination or we've copied all our intended rows. for (src_cursor.y..src_cursor.page.size.rows) |src_y| { - if (src_y > 0) { - // We're done with this row, if this row isn't wrapped, we can - // move our destination cursor to the next row. - if (!src_cursor.page_row.wrap) { - dst_cursor.cursorScroll(); - } - } - + const prev_wrap = src_cursor.page_row.wrap; src_cursor.cursorAbsolute(0, @intCast(src_y)); // Trim trailing empty cells if the row is not wrapped. If the @@ -720,6 +728,26 @@ fn reflowPage( const trailing_empty = src_cursor.countTrailingEmptyCells(); const cols_len = src_cursor.page.size.cols - trailing_empty; + if (cols_len == 0) { + // If the row is empty, we don't copy it. We count it as a + // blank line and continue to the next row. + blank_lines += 1; + continue; + } + + // We have data, if we have blank lines we need to create them first. + for (0..blank_lines) |_| { + dst_cursor.cursorScroll(); + } + + if (src_y > 0) { + // We're done with this row, if this row isn't wrapped, we can + // move our destination cursor to the next row. + if (!prev_wrap) { + dst_cursor.cursorScroll(); + } + } + for (src_cursor.x..cols_len) |src_x| { assert(src_cursor.x == src_x); @@ -3307,3 +3335,102 @@ test "PageList resize reflow less cols cursor in unchanged row" { try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); } + +test "PageList resize reflow less cols blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..1) |y| { + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.{ .active = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} + +test "PageList resize reflow less cols blank lines between" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + for (0..4) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + for (0..4) |x| { + const rac = page.getRowAndCell(x, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 5), s.totalRows()); + + var it = s.rowIterator(.{ .active = .{} }, null); + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + try testing.expect(!rac.row.wrap); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + } +} diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 226aeef5d4..2269577cfa 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2723,3 +2723,70 @@ test "Screen: resize less rows with full scrollback" { 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); +} From b6de7eca95ca726cc6dee592e4a8b36e09e62268 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 21:24:37 -0800 Subject: [PATCH 154/428] terminal/new: more reflow less cols tests --- src/terminal/Screen.zig | 3 ++ src/terminal/new/Screen.zig | 89 +++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 30dc0e809d..54a5ad6d78 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7257,6 +7257,7 @@ test "Screen: resize less cols with reflow but row space" { 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; @@ -7281,6 +7282,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { } } +// X test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7305,6 +7307,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { } } +// X test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 2269577cfa..eb2315a7f1 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -604,6 +604,7 @@ pub fn resize( .y = self.cursor.y, }; + const old_cols = self.pages.cols; try self.pages.resize(.{ .rows = rows, .cols = cols, @@ -611,6 +612,13 @@ pub fn resize( .cursor = &cursor, }); + // 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 and cols < old_cols) { + self.pages.eraseRows(.{ .history = .{} }, null); + } + if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { self.cursor.x = cursor.x; self.cursor.y = cursor.y; @@ -2790,3 +2798,84 @@ test "Screen: resize less cols with reflow but row space" { 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); + } +} From af3224d5fba8d37534415153296974699623a3b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 21:31:52 -0800 Subject: [PATCH 155/428] terminal/new: more less cols tests --- src/terminal/Screen.zig | 4 + src/terminal/new/Screen.zig | 177 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 54a5ad6d78..07e81b0a09 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7341,6 +7341,7 @@ test "Screen: resize less cols with reflow previously wrapped" { } } +// X test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7369,6 +7370,7 @@ test "Screen: resize less cols with reflow and scrollback" { 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; @@ -7442,6 +7444,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { 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; @@ -7480,6 +7483,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { } } +// 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. diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index eb2315a7f1..aee1e5b974 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2879,3 +2879,180 @@ test "Screen: resize less cols with reflow previously wrapped" { 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 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); + } +} From 3530f13a7a2ef4a2ed736e03038f400237a432a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 21:50:58 -0800 Subject: [PATCH 156/428] terminal/new: clean up redundancies --- src/terminal/new/PageList.zig | 151 +++++++++++++--------------------- src/terminal/new/Screen.zig | 68 +++++---------- 2 files changed, 78 insertions(+), 141 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 76dedfba01..f6fe102249 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -390,7 +390,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { .gt => { // We grow rows after cols so that we can do our unwrapping/reflow // before we do a no-reflow grow. - try self.resizeGrowCols(cols, opts.cursor); + try self.resizeCols(cols, opts.cursor); try self.resizeWithoutReflow(opts); }, @@ -403,20 +403,20 @@ pub fn resize(self: *PageList, opts: Resize) !void { break :opts copy; }); - try self.resizeShrinkCols(cols, opts.cursor); + try self.resizeCols(cols, opts.cursor); }, } } -/// Resize the pagelist with reflow by removing columns. -fn resizeShrinkCols( +/// Resize the pagelist with reflow by adding or removing columns. +fn resizeCols( self: *PageList, cols: size.CellCountInt, cursor: ?*Resize.Cursor, ) !void { - assert(cols < self.cols); + assert(cols != self.cols); - // Our new capacity, ensure we can shrink to it. + // Our new capacity, ensure we can fit the cols const cap = try std_capacity.adjust(.{ .cols = cols }); // If we are given a cursor, we need to calculate the row offset. @@ -436,93 +436,25 @@ fn resizeShrinkCols( // Go page by page and shrink the columns on a per-page basis. var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { - // Note: we can do a fast-path here if all of our rows in this - // page already fit within the new capacity. In that case we can - // do a non-reflow resize. - try self.reflowPage(cap, chunk.page, cursor); - } - - // If our total rows is less than our active rows, we need to grow. - // This can happen if you're growing columns such that enough active - // rows unwrap that we no longer have enough. - var node_it = self.pages.first; - var total: usize = 0; - while (node_it) |node| : (node_it = node.next) { - total += node.data.size.rows; - if (total >= self.rows) break; - } else { - for (total..self.rows) |_| _ = try self.grow(); - } - - if (cursor) |c| cursor: { - const offset = c.offset orelse break :cursor; - var active_it = self.rowIterator(.{ .active = .{} }, null); - var y: size.CellCountInt = 0; - while (active_it.next()) |it_offset| { - if (it_offset.page == offset.page and - it_offset.row_offset == offset.row_offset) - { - c.y = y; - break :cursor; - } - - y += 1; - } else { - // Cursor moved off the screen into the scrollback. - c.x = 0; - c.y = 0; - } - } - - // Update our cols - self.cols = cols; -} - -/// Resize the pagelist with reflow by adding columns. -fn resizeGrowCols( - self: *PageList, - cols: size.CellCountInt, - cursor: ?*Resize.Cursor, -) !void { - assert(cols > self.cols); - - // Our new capacity, ensure we can grow to it. - const cap = try std_capacity.adjust(.{ .cols = cols }); - - // If we are given a cursor, we need to calculate the row offset. - if (cursor) |c| { - if (c.offset == null) { - const tl = self.getTopLeft(.active); - c.offset = tl.forward(c.y) orelse fail: { - // This should never happen, but its not critical enough to - // set an assertion and fail the program. The caller should ALWAYS - // input a valid x/y.. - log.err("cursor offset not found, resize will set wrong cursor", .{}); - break :fail null; - }; - } - } - - // Go page by page and grow the columns on a per-page basis. - var it = self.pageIterator(.{ .screen = .{} }, null); - while (it.next()) |chunk| { - const page = &chunk.page.data; - const rows = page.rows.ptr(page.memory)[0..page.size.rows]; - // Fast-path: none of our rows are wrapped. In this case we can - // treat this like a no-reflow resize. - const wrapped = wrapped: for (rows) |row| { - assert(!row.wrap_continuation); // TODO - if (row.wrap) break :wrapped true; - } else false; - if (!wrapped) { - try self.resizeWithoutReflowGrowCols(cap, chunk); - continue; + // treat this like a no-reflow resize. This only applies if we + // are growing columns. + if (cols > self.cols) { + const page = &chunk.page.data; + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + const wrapped = wrapped: for (rows) |row| { + assert(!row.wrap_continuation); // TODO + if (row.wrap) break :wrapped true; + } else false; + if (!wrapped) { + try self.resizeWithoutReflowGrowCols(cap, chunk, cursor); + continue; + } } - // Slow path, we have a wrapped row. We need to reflow the text. - // This is painful because we basically need to rewrite the entire - // page sequentially. + // Note: we can do a fast-path here if all of our rows in this + // page already fit within the new capacity. In that case we can + // do a non-reflow resize. try self.reflowPage(cap, chunk.page, cursor); } @@ -575,6 +507,10 @@ fn resizeGrowCols( } y += 1; + } else { + // Cursor moved off the screen into the scrollback. + c.x = 0; + c.y = 0; } } @@ -951,7 +887,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { - try self.resizeWithoutReflowGrowCols(cap, chunk); + try self.resizeWithoutReflowGrowCols(cap, chunk, opts.cursor); } self.cols = cols; @@ -964,6 +900,7 @@ fn resizeWithoutReflowGrowCols( self: *PageList, cap: Capacity, chunk: PageIterator.Chunk, + cursor: ?*Resize.Cursor, ) !void { assert(cap.cols > self.cols); const page = &chunk.page.data; @@ -1000,11 +937,35 @@ fn resizeWithoutReflowGrowCols( var copied: usize = 0; while (copied < page.size.rows) { const new_page = try self.createPage(cap); + + // The length we can copy into the new page is at most the number + // of rows in our cap. But if we can finish our source page we use that. const len = @min(cap.rows, page.size.rows - copied); - copied += len; new_page.data.size.rows = len; - try new_page.data.cloneFrom(page, 0, len); + + // The range of rows we're copying from the old page. + const y_start = copied; + const y_end = copied + len; + try new_page.data.cloneFrom(page, y_start, y_end); + copied += len; + + // Insert our new page self.pages.insertBefore(chunk.page, new_page); + + // If we have a cursor, we need to update the row offset if it + // matches what we just copied. + if (cursor) |c| cursor: { + const offset = c.offset orelse break :cursor; + if (offset.page == chunk.page and + offset.row_offset >= y_start and + offset.row_offset < y_end) + { + c.offset = .{ + .page = new_page, + .row_offset = offset.row_offset - y_start, + }; + } + } } assert(copied == page.size.rows); @@ -3064,7 +3025,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { } // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + var cursor: Resize.Cursor = .{ .x = 1, .y = 0 }; // Resize try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); @@ -3072,7 +3033,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 2), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); } diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index aee1e5b974..a8b5bb740b 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -582,48 +582,7 @@ pub fn resize( cols: size.CellCountInt, rows: size.CellCountInt, ) !void { - if (self.pages.cols == cols) { - // No resize necessary - if (self.pages.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(cols, rows); - return; - } - - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - var cursor: PageList.Resize.Cursor = .{ - .x = self.cursor.x, - .y = self.cursor.y, - }; - - const old_cols = self.pages.cols; - try self.pages.resize(.{ - .rows = rows, - .cols = cols, - .reflow = true, - .cursor = &cursor, - }); - - // 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 and cols < old_cols) { - self.pages.eraseRows(.{ .history = .{} }, null); - } - - if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { - self.cursor.x = cursor.x; - self.cursor.y = cursor.y; - self.cursorReload(); - } + try self.resizeInternal(cols, rows, true); } /// Resize the screen without any reflow. In this mode, columns/rows will @@ -634,27 +593,44 @@ pub fn resizeWithoutReflow( 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; + + // Create a resize cursor. The resize operation uses this to keep our + // cursor over the same cell if possible. var cursor: PageList.Resize.Cursor = .{ .x = self.cursor.x, .y = self.cursor.y, }; - const old_rows = self.pages.rows; - + // Perform the resize operation. This will update cursor by reference. try self.pages.resize(.{ .rows = rows, .cols = cols, - .reflow = false, + .reflow = reflow, .cursor = &cursor, }); // 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 and rows < old_rows) { + 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. if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { self.cursor.x = cursor.x; self.cursor.y = cursor.y; From a6ad489c975d3b7aa862ba2d115215d692c96231 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 22:10:08 -0800 Subject: [PATCH 157/428] terminal/new: fix issue with resizing when cursor is in blank trailing cell --- src/terminal/Screen.zig | 6 +++ src/terminal/new/PageList.zig | 85 +++++++++++++++++++++++++++++++++++ src/terminal/new/Screen.zig | 45 +++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 07e81b0a09..5cce3f0dbf 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6556,6 +6556,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { 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; @@ -6583,6 +6584,11 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { 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); diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index f6fe102249..acef3957ab 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -760,6 +760,29 @@ fn reflowPage( // Move both our cursors forward src_cursor.cursorForward(); dst_cursor.cursorForward(); + } else cursor: { + // We made it through all our source columns. As a final edge + // case, if our cursor is in one of the blanks, we update it + // to the edge of this page. + + // If we have no trailing empty cells, it can't be in the blanks. + if (trailing_empty == 0) break :cursor; + + // If we have no cursor, nothing to update. + const c = cursor orelse break :cursor; + const offset = c.offset orelse break :cursor; + + // If our cursor is on this page, and our x is greater than + // our end, we update to the edge. + if (&offset.page.data == src_cursor.page and + offset.row_offset == src_cursor.y and + c.x >= cols_len) + { + c.offset = .{ + .page = dst_node, + .row_offset = dst_cursor.y, + }; + } } } else { // We made it through all our source rows, we're done. @@ -3297,6 +3320,68 @@ test "PageList resize reflow less cols cursor in unchanged row" { try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); } +test "PageList resize reflow less cols cursor in blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in a blank cell + var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + + // Resize + try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 2), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} + +test "PageList resize reflow less cols cursor in final blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Set our cursor to be in the final cell of our resized + var cursor: Resize.Cursor = .{ .x = 3, .y = 0 }; + + // Resize + try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(@as(size.CellCountInt, 3), cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); +} + test "PageList resize reflow less cols blank lines" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index a8b5bb740b..af6ed0e273 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2258,6 +2258,51 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { 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 with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; From e83936701daeb80058936ea4469a0bade509057b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 22:16:16 -0800 Subject: [PATCH 158/428] terminal/new: semantic prompt saving tests --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 26 +++++++++++++++++++++ src/terminal/new/Screen.zig | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5cce3f0dbf..1c7e899174 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6595,6 +6595,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { 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; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index acef3957ab..288558c0cb 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -3122,6 +3122,32 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); } +test "PageList resize reflow more cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 1); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + test "PageList resize reflow less cols no wrapped rows" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index af6ed0e273..09992dba1c 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -2303,6 +2303,49 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { // } } +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; From ac007221b317d12dc5c80a773f5b1b4beea69cd3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 3 Mar 2024 22:25:52 -0800 Subject: [PATCH 159/428] terminal/new: copy graphemes for reflow --- src/terminal/Screen.zig | 2 + src/terminal/new/PageList.zig | 104 +++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1c7e899174..b582b05015 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6643,6 +6643,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { } } +// X test "Screen: resize more cols grapheme map" { const testing = std.testing; const alloc = testing.allocator; @@ -7139,6 +7140,7 @@ test "Screen: resize less cols trailing background colors" { } } +// X test "Screen: resize less cols with graphemes" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 288558c0cb..246dc6d265 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -714,12 +714,29 @@ fn reflowPage( .codepoint => { dst_cursor.page_cell.* = src_cursor.page_cell.*; - // TODO: style copy }, - else => @panic("TODO"), + .codepoint_grapheme => { + // We copy the cell like normal but we have to reset the + // tag because this is used for fast-path detection in + // appendGrapheme. + dst_cursor.page_cell.* = src_cursor.page_cell.*; + dst_cursor.page_cell.content_tag = .codepoint; + + // Copy the graphemes + const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; + for (src_cps) |cp| { + try dst_cursor.page.appendGrapheme( + dst_cursor.page_row, + dst_cursor.page_cell, + cp, + ); + } + }, } + // TODO: style copy + // If our original cursor was on this page, this x/y then // we need to update to the new location. if (cursor) |c| cursor: { @@ -3253,6 +3270,89 @@ test "PageList resize reflow less cols wrapped rows" { } } +test "PageList resize reflow less cols wrapped rows with graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, null); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + + const rac = page.getRowAndCell(2, y); + try page.appendGrapheme(rac.row, rac.cell, 'A'); + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Active moves due to scrollback + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, pt); + } + + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + var it = s.rowIterator(.{ .screen = .{} }, null); + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + try testing.expectEqual(@as(u21, 'A'), cps[0]); + } + { + // First row should be wrapped + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + try testing.expectEqual(@as(u21, 'A'), cps[0]); + } +} test "PageList resize reflow less cols cursor in wrapped row" { const testing = std.testing; const alloc = testing.allocator; From aeacc02614f8e5ecb68e3b300d7f48e314aee42a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 10:39:42 -0800 Subject: [PATCH 160/428] terminal/new: reflow copies styles --- src/terminal/new/PageList.zig | 68 ++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 246dc6d265..cf8ae36735 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -626,6 +626,12 @@ const ReflowCursor = struct { /// is one giant wrapped line), this can be a very expensive operation. That /// doesn't really happen in typical terminal usage so its not a case we /// optimize for today. Contributions welcome to optimize this. +/// +/// Conceptually, this is a simple process: we're effectively traversing +/// the old page and rewriting into the new page as if it were a text editor. +/// But, due to the edge cases, cursor tracking, and attempts at efficiency, +/// the code can be convoluted so this is going to be a heavily commented +/// function. fn reflowPage( self: *PageList, cap: Capacity, @@ -735,7 +741,20 @@ fn reflowPage( }, } - // TODO: style copy + // If the source cell has a style, we need to copy it. + if (src_cursor.page_cell.style_id != stylepkg.default_id) { + const src_style = src_cursor.page.styles.lookupId( + src_cursor.page.memory, + src_cursor.page_cell.style_id, + ).?.*; + + const dst_md = try dst_cursor.page.styles.upsert( + dst_cursor.page.memory, + src_style, + ); + dst_md.ref += 1; + dst_cursor.page_cell.style_id = dst_md.id; + } // If our original cursor was on this page, this x/y then // we need to update to the new location. @@ -3606,3 +3625,50 @@ test "PageList resize reflow less cols blank lines between" { try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); } } + +test "PageList resize reflow less cols copy style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Create a style + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_md = try page.styles.upsert(page.memory, style); + + for (0..s.cols - 1) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + .style_id = style_md.id, + }; + + style_md.ref += 1; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + var it = s.rowIterator(.{ .active = .{} }, null); + while (it.next()) |offset| { + for (0..s.cols - 1) |x| { + const rac = offset.rowAndCell(x); + const style_id = rac.cell.style_id; + try testing.expect(style_id != 0); + + const style = offset.page.data.styles.lookupId( + offset.page.data.memory, + style_id, + ).?; + try testing.expect(style.flags.bold); + } + } +} From 93e63d5356813a55a1fe8dfdd92bb0d3b8b276d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 11:05:34 -0800 Subject: [PATCH 161/428] terminal/new: pagelist resize preserves semantic prompt --- src/terminal/Screen.zig | 1 + src/terminal/new/PageList.zig | 119 +++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b582b05015..0202c2144c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7183,6 +7183,7 @@ test "Screen: resize less cols with graphemes" { } } +// X test "Screen: resize less cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index cf8ae36735..df1ef86bb7 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -593,6 +593,10 @@ const ReflowCursor = struct { self.y = y; } + fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { + self.page_row.semantic_prompt = other.semantic_prompt; + } + fn countTrailingEmptyCells(self: *const ReflowCursor) usize { // If the row is wrapped, all empty cells are meaningful. if (self.page_row.wrap) return 0; @@ -604,6 +608,10 @@ const ReflowCursor = struct { if (!cells[rev_i].isEmpty()) return i; } + // If the row has a semantic prompt then the blank row is meaningful + // so we always return all but one so that the row is drawn. + if (self.page_row.semantic_prompt != .unknown) return len - 1; + return len; } }; @@ -653,6 +661,10 @@ fn reflowPage( const dst_node = try self.createPage(cap); dst_node.data.size.rows = 1; var dst_cursor = ReflowCursor.init(&dst_node.data); + dst_cursor.copyRowMetadata(src_cursor.page_row); + + // Copy some initial metadata about the row + //dst_cursor.page_row.semantic_prompt = src_cursor.page_row.semantic_prompt; // Our new page goes before our src node. This will append it to any // previous pages we've created. @@ -685,11 +697,19 @@ fn reflowPage( if (src_y > 0) { // We're done with this row, if this row isn't wrapped, we can // move our destination cursor to the next row. - if (!prev_wrap) { + // + // The blank_lines == 0 condition is because if we were prefixed + // with blank lines, we handled the scroll already above. + if (!prev_wrap and blank_lines == 0) { dst_cursor.cursorScroll(); } + + dst_cursor.copyRowMetadata(src_cursor.page_row); } + // Reset our blank line count since handled it all above. + blank_lines = 0; + for (src_cursor.x..cols_len) |src_x| { assert(src_cursor.x == src_x); @@ -705,6 +725,7 @@ fn reflowPage( dst_cursor.page_row.wrap = true; dst_cursor.cursorScroll(); dst_cursor.page_row.wrap_continuation = true; + dst_cursor.copyRowMetadata(src_cursor.page_row); } switch (src_cursor.page_cell.content_tag) { @@ -3184,6 +3205,100 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { } } +test "PageList resize reflow less cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + try testing.expect(rac.row.wrap); + try testing.expect(rac.row.semantic_prompt == .prompt); + } + { + const rac = page.getRowAndCell(0, 2); + try testing.expect(rac.row.semantic_prompt == .prompt); + } + } +} + +test "PageList resize reflow less cols no reflow preserves semantic prompt on first line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + +test "PageList resize reflow less cols wrap preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 4, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.semantic_prompt == .prompt); + } +} + test "PageList resize reflow less cols no wrapped rows" { const testing = std.testing; const alloc = testing.allocator; @@ -3600,7 +3715,7 @@ test "PageList resize reflow less cols blank lines between" { // Resize try s.resize(.{ .cols = 2, .reflow = true }); try testing.expectEqual(@as(usize, 2), s.cols); - try testing.expectEqual(@as(usize, 5), s.totalRows()); + try testing.expectEqual(@as(usize, 4), s.totalRows()); var it = s.rowIterator(.{ .active = .{} }, null); { From 6b90b6f2b07225c76896519cf95b0b1b8f5e6ddb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 11:33:06 -0800 Subject: [PATCH 162/428] terminal/new: pagelist resize to 1 col deletes wide chars --- src/terminal/new/PageList.zig | 170 ++++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 50 deletions(-) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index df1ef86bb7..bfa1b19430 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -593,10 +593,6 @@ const ReflowCursor = struct { self.y = y; } - fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { - self.page_row.semantic_prompt = other.semantic_prompt; - } - fn countTrailingEmptyCells(self: *const ReflowCursor) usize { // If the row is wrapped, all empty cells are meaningful. if (self.page_row.wrap) return 0; @@ -614,6 +610,10 @@ const ReflowCursor = struct { return len; } + + fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { + self.page_row.semantic_prompt = other.semantic_prompt; + } }; /// Reflow the given page into the new capacity. The new capacity can have @@ -728,53 +728,78 @@ fn reflowPage( dst_cursor.copyRowMetadata(src_cursor.page_row); } - switch (src_cursor.page_cell.content_tag) { - // These are guaranteed to have no styling data and no - // graphemes, a fast path. - .bg_color_palette, - .bg_color_rgb, - => { - assert(!src_cursor.page_cell.hasStyling()); - assert(!src_cursor.page_cell.hasGrapheme()); - dst_cursor.page_cell.* = src_cursor.page_cell.*; - }, - - .codepoint => { - dst_cursor.page_cell.* = src_cursor.page_cell.*; - }, - - .codepoint_grapheme => { - // We copy the cell like normal but we have to reset the - // tag because this is used for fast-path detection in - // appendGrapheme. - dst_cursor.page_cell.* = src_cursor.page_cell.*; - dst_cursor.page_cell.content_tag = .codepoint; - - // Copy the graphemes - const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; - for (src_cps) |cp| { - try dst_cursor.page.appendGrapheme( - dst_cursor.page_row, - dst_cursor.page_cell, - cp, - ); - } - }, - } + // A rare edge case. If we're resizing down to 1 column + // and the source is a non-narrow character, we reset the + // cell to a narrow blank and we skip to the next cell. + if (cap.cols == 1 and src_cursor.page_cell.wide != .narrow) { + switch (src_cursor.page_cell.wide) { + .narrow => unreachable, + + // Wide char, we delete it, reset it to narrow, + // and skip forward. + .wide => { + dst_cursor.page_cell.content.codepoint = 0; + dst_cursor.page_cell.wide = .narrow; + src_cursor.cursorForward(); + continue; + }, + + // Skip spacer tails since we should've already + // handled them in the previous cell. + .spacer_tail => {}, + + // TODO: test? + .spacer_head => {}, + } + } else { + switch (src_cursor.page_cell.content_tag) { + // These are guaranteed to have no styling data and no + // graphemes, a fast path. + .bg_color_palette, + .bg_color_rgb, + => { + assert(!src_cursor.page_cell.hasStyling()); + assert(!src_cursor.page_cell.hasGrapheme()); + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint => { + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint_grapheme => { + // We copy the cell like normal but we have to reset the + // tag because this is used for fast-path detection in + // appendGrapheme. + dst_cursor.page_cell.* = src_cursor.page_cell.*; + dst_cursor.page_cell.content_tag = .codepoint; + + // Copy the graphemes + const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; + for (src_cps) |cp| { + try dst_cursor.page.appendGrapheme( + dst_cursor.page_row, + dst_cursor.page_cell, + cp, + ); + } + }, + } - // If the source cell has a style, we need to copy it. - if (src_cursor.page_cell.style_id != stylepkg.default_id) { - const src_style = src_cursor.page.styles.lookupId( - src_cursor.page.memory, - src_cursor.page_cell.style_id, - ).?.*; - - const dst_md = try dst_cursor.page.styles.upsert( - dst_cursor.page.memory, - src_style, - ); - dst_md.ref += 1; - dst_cursor.page_cell.style_id = dst_md.id; + // If the source cell has a style, we need to copy it. + if (src_cursor.page_cell.style_id != stylepkg.default_id) { + const src_style = src_cursor.page.styles.lookupId( + src_cursor.page.memory, + src_cursor.page_cell.style_id, + ).?.*; + + const dst_md = try dst_cursor.page.styles.upsert( + dst_cursor.page.memory, + src_style, + ); + dst_md.ref += 1; + dst_cursor.page_cell.style_id = dst_md.id; + } } // If our original cursor was on this page, this x/y then @@ -3787,3 +3812,48 @@ test "PageList resize reflow less cols copy style" { } } } + +test "PageList resize reflow less cols to eliminate a wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 1, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 1, .reflow = true }); + try testing.expectEqual(@as(usize, 1), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} From 374b7f8f63222c4de7b382495b8ac4d0334a9b23 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 13:06:56 -0800 Subject: [PATCH 163/428] terminal/new: wrap wide chars in resize reflow --- src/terminal/new/PageList.zig | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index bfa1b19430..cbd9e86887 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -721,6 +721,21 @@ fn reflowPage( // src_cursor.page_cell.content.codepoint, // }); + // If we have a wide char at the end of our page we need + // to insert a spacer head and wrap. + if (cap.cols > 1 and + src_cursor.page_cell.wide == .wide and + dst_cursor.x == cap.cols - 1) + { + dst_cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + // TODO: update cursor + dst_cursor.cursorForward(); + } + if (dst_cursor.pending_wrap) { dst_cursor.page_row.wrap = true; dst_cursor.cursorScroll(); @@ -3857,3 +3872,71 @@ test "PageList resize reflow less cols to eliminate a wide char" { } } } + +test "PageList resize reflow less cols to wrap a wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 1, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} From 2af00b0dbf8f637382cf5e22298b06fe51c245d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 13:29:25 -0800 Subject: [PATCH 164/428] terminal/new: handle unwrapping wide spacer heads --- src/terminal/new/PageList.zig | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index cbd9e86887..5ce829148d 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -736,6 +736,16 @@ fn reflowPage( dst_cursor.cursorForward(); } + // If we have a spacer head and we're not at the end then + // we want to unwrap it and eliminate the head. + if (cap.cols > 1 and + src_cursor.page_cell.wide == .spacer_head and + dst_cursor.x != cap.cols - 1) + { + src_cursor.cursorForward(); + continue; + } + if (dst_cursor.pending_wrap) { dst_cursor.page_row.wrap = true; dst_cursor.cursorScroll(); @@ -3245,6 +3255,78 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { } } +test "PageList resize reflow more cols unwrap wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(!rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; From 6c0166a3d167171ebe2959a6582bc52a26f7d72d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 13:31:51 -0800 Subject: [PATCH 165/428] terminal/new: unwrapping requiring wrapping with spacer head --- src/terminal/new/PageList.zig | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 5ce829148d..9cfbc22c92 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -3327,6 +3327,86 @@ test "PageList resize reflow more cols unwrap wide spacer head" { } } +test "PageList resize reflow more cols unwrap still requires wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} test "PageList resize reflow less cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; From 57deadce9765b78fdf7fddb0ecdca8efd216fd65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:08:52 -0800 Subject: [PATCH 166/428] terminal/new: more reflow tests with wide chars --- src/terminal/Screen.zig | 2 + src/terminal/new/PageList.zig | 85 ++++++++++++++----------- src/terminal/new/Screen.zig | 115 ++++++++++++++++++++++++++++++++-- src/terminal/new/page.zig | 4 +- 4 files changed, 165 insertions(+), 41 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0202c2144c..58eb8832e9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7546,6 +7546,7 @@ test "Screen: resize more rows then shrink again" { } } +// X test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; @@ -7580,6 +7581,7 @@ test "Screen: resize less cols to eliminate wide char" { 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; diff --git a/src/terminal/new/PageList.zig b/src/terminal/new/PageList.zig index 9cfbc22c92..a496815357 100644 --- a/src/terminal/new/PageList.zig +++ b/src/terminal/new/PageList.zig @@ -727,12 +727,13 @@ fn reflowPage( src_cursor.page_cell.wide == .wide and dst_cursor.x == cap.cols - 1) { + reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + dst_cursor.page_cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 0 }, .wide = .spacer_head, }; - // TODO: update cursor dst_cursor.cursorForward(); } @@ -742,6 +743,7 @@ fn reflowPage( src_cursor.page_cell.wide == .spacer_head and dst_cursor.x != cap.cols - 1) { + reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); src_cursor.cursorForward(); continue; } @@ -829,40 +831,7 @@ fn reflowPage( // If our original cursor was on this page, this x/y then // we need to update to the new location. - if (cursor) |c| cursor: { - const offset = c.offset orelse break :cursor; - if (&offset.page.data == src_cursor.page and - offset.row_offset == src_cursor.y and - c.x == src_cursor.x) - { - // std.log.warn("c.x={} c.y={} dst_x={} dst_y={} src_y={}", .{ - // c.x, - // c.y, - // dst_cursor.x, - // dst_cursor.y, - // src_cursor.y, - // }); - - // Column always matches our dst x - c.x = dst_cursor.x; - - // Our y is more complicated. The cursor y is the active - // area y, not the row offset. Our cursors are row offsets. - // Instead of calculating the active area coord, we can - // better calculate the CHANGE in coordinate by subtracting - // our dst from src which will calculate how many rows - // we unwrapped to get here. - // - // Note this doesn't handle when we pull down scrollback. - // See the cursor updates in resizeGrowCols for that. - //c.y -|= src_cursor.y - dst_cursor.y; - - c.offset = .{ - .page = dst_node, - .row_offset = dst_cursor.y, - }; - } - } + reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); // Move both our cursors forward src_cursor.cursorForward(); @@ -902,6 +871,52 @@ fn reflowPage( self.destroyPage(node); } +/// This updates the cursor offset if the cursor is exactly on the cell +/// we're currently reflowing. This can then be fixed up later to an exact +/// x/y (see resizeCols). +fn reflowUpdateCursor( + cursor: ?*Resize.Cursor, + src_cursor: *const ReflowCursor, + dst_cursor: *const ReflowCursor, + dst_node: *List.Node, +) void { + const c = cursor orelse return; + + // If our original cursor was on this page, this x/y then + // we need to update to the new location. + const offset = c.offset orelse return; + if (&offset.page.data != src_cursor.page or + offset.row_offset != src_cursor.y or + c.x != src_cursor.x) return; + + // std.log.warn("c.x={} c.y={} dst_x={} dst_y={} src_y={}", .{ + // c.x, + // c.y, + // dst_cursor.x, + // dst_cursor.y, + // src_cursor.y, + // }); + + // Column always matches our dst x + c.x = dst_cursor.x; + + // Our y is more complicated. The cursor y is the active + // area y, not the row offset. Our cursors are row offsets. + // Instead of calculating the active area coord, we can + // better calculate the CHANGE in coordinate by subtracting + // our dst from src which will calculate how many rows + // we unwrapped to get here. + // + // Note this doesn't handle when we pull down scrollback. + // See the cursor updates in resizeGrowCols for that. + //c.y -|= src_cursor.y - dst_cursor.y; + + c.offset = .{ + .page = dst_node, + .row_offset = dst_cursor.y, + }; +} + fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { if (opts.rows) |rows| { switch (std.math.order(rows, self.rows)) { diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 09992dba1c..bef04b7864 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -931,17 +931,48 @@ fn testWriteString(self: *Screen, text: []const u8) !void { ref.* += 1; self.cursor.page_row.styled = true; } + }, - if (self.cursor.x + 1 < self.pages.cols) { - self.cursorRight(1); - } else { - self.cursor.pending_wrap = 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, + }; }, - 2 => @panic("todo double-width"), else => unreachable, } + + if (self.cursor.x + 1 < self.pages.cols) { + self.cursorRight(1); + } else { + self.cursor.pending_wrap = true; + } } } @@ -3120,3 +3151,77 @@ test "Screen: resize more rows then shrink again" { 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); + } +} diff --git a/src/terminal/new/page.zig b/src/terminal/new/page.zig index 28c7402126..045ad29af6 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal/new/page.zig @@ -774,9 +774,11 @@ pub const Cell = packed struct(u64) { /// Returns true if the cell has no text or styling. pub fn isEmpty(self: Cell) bool { return switch (self.content_tag) { + // Textual cells are empty if they have no text and are narrow. + // The "narrow" requirement is because wide spacers are meaningful. .codepoint, .codepoint_grapheme, - => !self.hasText(), + => !self.hasText() and self.wide == .narrow, .bg_color_palette, .bg_color_rgb, From d139e9c611f9fb2e80522417a1646d3a3300300d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:15:21 -0800 Subject: [PATCH 167/428] terminal/new: screen passes all resize tests --- src/terminal/Screen.zig | 5 + src/terminal/new/Screen.zig | 188 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 58eb8832e9..8bad89ba97 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7617,6 +7617,7 @@ test "Screen: resize less cols to wrap wide char" { } } +// X test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator; @@ -7652,6 +7653,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { } } +// X test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; @@ -7693,6 +7695,7 @@ test "Screen: resize more cols with wide spacer head" { } } +// X test "Screen: resize less cols preserves grapheme cluster" { const testing = std.testing; const alloc = testing.allocator; @@ -7723,6 +7726,7 @@ test "Screen: resize less cols preserves grapheme cluster" { } } +// X test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator; @@ -7762,6 +7766,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { } } +// X test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index bef04b7864..252b757fcb 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -3225,3 +3225,191 @@ test "Screen: resize less cols to wrap wide char" { 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); + } +} From c9479c78b4cf143ca643f637e4b094a474253b38 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:20:05 -0800 Subject: [PATCH 168/428] terminal/new: resize tests --- src/terminal/new/Terminal.zig | 134 ++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index ab9fd1a208..d6b641527c 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -3,6 +3,10 @@ //! 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; @@ -1950,6 +1954,61 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { return stream.getWritten(); } +/// Resize the underlying terminal. +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 + if (self.cols != cols) { + self.tabstops.deinit(alloc); + self.tabstops = try Tabstops.init(alloc, cols, 8); + } + + // If we're making the screen smaller, dealloc the unused items. + if (self.active_screen == .primary) { + self.clearPromptForResize(); + if (self.modes.get(.wraparound)) { + try self.screen.resize(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + } + try self.secondary_screen.resizeWithoutReflow(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + if (self.modes.get(.wraparound)) { + try self.secondary_screen.resize(rows, cols); + } else { + try self.secondary_screen.resizeWithoutReflow(rows, cols); + } + } + + // Set our size + self.cols = cols; + self.rows = rows; + + // Reset the scrolling region + self.scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }; +} + +/// If shell_redraws_prompt is true and we're on the primary screen, +/// 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; +} + /// Options for switching to the alternate screen. pub const AlternateScreenOptions = struct { cursor_save: bool = false, @@ -7455,3 +7514,78 @@ test "Terminal: fullReset status display" { 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); +} From 1f135f9d9e45dc20d49091347c805fb0e9bc5483 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:23:43 -0800 Subject: [PATCH 169/428] terminal/new: deccolm --- src/terminal/Terminal.zig | 10 +++ src/terminal/new/Terminal.zig | 139 ++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2d44bd0a0d..5ff2591cbe 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5401,6 +5401,7 @@ test "Terminal: eraseChars protected attributes ignored with dec set" { } } +// 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. @@ -5416,6 +5417,7 @@ test "Terminal: resize less cols with wide char then print" { 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" { @@ -5435,6 +5437,7 @@ test "Terminal: resize with left and right margin set" { try t.resize(alloc, cols, rows); } +// X // https://github.com/mitchellh/ghostty/issues/1343 test "Terminal: resize with wraparound off" { const alloc = testing.allocator; @@ -5456,6 +5459,7 @@ test "Terminal: resize with wraparound off" { try testing.expectEqualStrings("01", str); } +// X test "Terminal: resize with wraparound on" { const alloc = testing.allocator; const cols = 4; @@ -5589,6 +5593,7 @@ test "Terminal: saveCursor origin mode" { } } +// X test "Terminal: saveCursor resize" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -7468,6 +7473,7 @@ 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); @@ -7480,6 +7486,7 @@ test "Terminal: DECCOLM without DEC mode 40" { try testing.expect(!t.modes.get(.@"132_column")); } +// X test "Terminal: DECCOLM unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7491,6 +7498,7 @@ test "Terminal: DECCOLM unset" { 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); @@ -7506,6 +7514,7 @@ test "Terminal: DECCOLM resets pending wrap" { 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); @@ -7525,6 +7534,7 @@ test "Terminal: DECCOLM preserves SGR bg" { } } +// X test "Terminal: DECCOLM resets scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index d6b641527c..e4e1077b03 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1954,6 +1954,46 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { return stream.getWritten(); } +/// The modes for DECCOLM. +pub const DeccolmMode = enum(u1) { + @"80_cols" = 0, + @"132_cols" = 1, +}; + +/// DECCOLM changes the terminal width between 80 and 132 columns. This +/// function call will do NOTHING unless `setDeccolmSupported` has been +/// called with "true". +/// +/// This breaks the expectation around modern terminals that they resize +/// with the window. This will fix the grid at either 80 or 132 columns. +/// The rows will continue to be variable. +pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if (!self.modes.get(.enable_mode_3)) { + self.modes.set(.@"132_column", false); + return; + } + + // Enable it + self.modes.set(.@"132_column", mode == .@"132_cols"); + + // Resize to the requested size + try self.resize( + alloc, + switch (mode) { + .@"132_cols" => 132, + .@"80_cols" => 80, + }, + self.rows, + ); + + // Erase our display and move our cursor. + self.eraseDisplay(.complete, false); + self.setCursorPos(1, 1); +} + /// Resize the underlying terminal. pub fn resize( self: *Terminal, @@ -6409,6 +6449,24 @@ test "Terminal: saveCursor origin mode" { } } +test "Terminal: saveCursor resize" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, 10); + t.saveCursor(); + try t.resize(alloc, 5, 5); + try t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -7589,3 +7647,84 @@ test "Terminal: resize with wraparound on" { 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); +} From ff4a0fce7fd0dcd534687de8d1896bb55153bcf0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:39:36 -0800 Subject: [PATCH 170/428] terminal/new: add scrollViewport --- src/terminal/main.zig | 1 + src/terminal/new/Terminal.zig | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/terminal/main.zig b/src/terminal/main.zig index ab5bad4d6c..55625f7a69 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -25,6 +25,7 @@ pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; pub const Terminal = @import("Terminal.zig"); +//pub const Terminal = new.Terminal; pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); pub const Screen = @import("Screen.zig"); diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index e4e1077b03..b444f48886 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1195,6 +1195,27 @@ pub fn scrollUp(self: *Terminal, count: usize) void { 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 From 8d81754f1727b29874b349ce08185cfc90c754aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 14:41:26 -0800 Subject: [PATCH 171/428] terminal/new: set/gwd pwd --- src/terminal/new/Terminal.zig | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index b444f48886..8913556404 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -2070,6 +2070,19 @@ fn clearPromptForResize(self: *Terminal) void { _ = self; } +/// Set the pwd for the terminal. +pub fn setPwd(self: *Terminal, pwd: []const u8) !void { + self.pwd.clearRetainingCapacity(); + try self.pwd.appendSlice(pwd); +} + +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[]const u8 { + if (self.pwd.items.len == 0) return null; + return self.pwd.items; +} + /// Options for switching to the alternate screen. pub const AlternateScreenOptions = struct { cursor_save: bool = false, From dec2fd5742b5245746fe42f9e1f901ef3abf723a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 17:26:05 -0800 Subject: [PATCH 172/428] terminal/new: some missing APIs --- src/terminal/new/Screen.zig | 31 +++++++++++++++++++++++++++++++ src/terminal/new/Terminal.zig | 15 +++++++++++++++ src/terminal/new/main.zig | 5 ++--- src/terminal/new/point.zig | 24 ++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 252b757fcb..f4581b722a 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -61,6 +61,11 @@ pub const Cursor = struct { 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, @@ -87,6 +92,11 @@ pub const Cursor = struct { 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, @@ -445,6 +455,11 @@ pub fn scrollClear(self: *Screen) !void { 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. @@ -808,6 +823,22 @@ pub fn manualStyleUpdate(self: *Screen) !void { 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 { + _ = self; + _ = alloc; + _ = sel; + _ = trim; + @panic("TODO"); +} + /// 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. diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig index 8913556404..51f5ddf58b 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal/new/Terminal.zig @@ -1885,6 +1885,21 @@ pub fn decaln(self: *Terminal) !void { } } +/// 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, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screen.setAttribute(attr); diff --git a/src/terminal/new/main.zig b/src/terminal/new/main.zig index 312dafc16d..22e66466fd 100644 --- a/src/terminal/new/main.zig +++ b/src/terminal/new/main.zig @@ -1,8 +1,10 @@ const builtin = @import("builtin"); pub const page = @import("page.zig"); +pub const point = @import("point.zig"); pub const PageList = @import("PageList.zig"); pub const Terminal = @import("Terminal.zig"); +pub const Screen = @import("Screen.zig"); pub const Page = page.Page; test { @@ -11,9 +13,6 @@ test { // todo: make top-level imports _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); - _ = @import("page.zig"); - _ = @import("Screen.zig"); - _ = @import("point.zig"); _ = @import("size.zig"); _ = @import("style.zig"); } diff --git a/src/terminal/new/point.zig b/src/terminal/new/point.zig index e0961e2015..00350f2651 100644 --- a/src/terminal/new/point.zig +++ b/src/terminal/new/point.zig @@ -2,8 +2,7 @@ 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 +/// 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. @@ -69,3 +68,24 @@ pub const Point = union(Tag) { }; } }; + +/// 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; + } + + const TerminalScreen = @import("Screen.zig"); + pub fn toScreen(_: Viewport, _: *const TerminalScreen) Screen { + @panic("TODO"); + } +}; + +/// A point in the terminal that is in relation to the entire screen. +pub const Screen = struct { + x: usize = 0, + y: usize = 0, +}; From 100e6ed254b2481b64d63cb4533e67ba04315e01 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Mar 2024 21:58:50 -0800 Subject: [PATCH 173/428] terminal/new => terminal2 so we can figure out what depends on what --- src/bench/stream.zig | 2 +- src/main_ghostty.zig | 1 + src/terminal/main.zig | 5 +- src/{terminal/new => terminal2}/PageList.zig | 0 src/{terminal/new => terminal2}/Screen.zig | 39 +- src/terminal2/Tabstops.zig | 231 ++++++ src/{terminal/new => terminal2}/Terminal.zig | 22 +- src/terminal2/ansi.zig | 114 +++ .../new => terminal2}/bitmap_allocator.zig | 0 src/terminal2/charsets.zig | 114 +++ src/terminal2/color.zig | 339 ++++++++ src/terminal2/csi.zig | 33 + src/{terminal/new => terminal2}/hash_map.zig | 0 src/terminal2/kitty.zig | 9 + src/{terminal/new => terminal2}/main.zig | 0 src/terminal2/modes.zig | 247 ++++++ src/terminal2/mouse_shape.zig | 115 +++ src/{terminal/new => terminal2}/page.zig | 6 +- src/{terminal/new => terminal2}/point.zig | 5 - src/terminal2/res/rgb.txt | 782 ++++++++++++++++++ src/terminal2/sgr.zig | 559 +++++++++++++ src/{terminal/new => terminal2}/size.zig | 0 src/{terminal/new => terminal2}/style.zig | 4 +- src/terminal2/x11_color.zig | 62 ++ 24 files changed, 2645 insertions(+), 44 deletions(-) rename src/{terminal/new => terminal2}/PageList.zig (100%) rename src/{terminal/new => terminal2}/Screen.zig (99%) create mode 100644 src/terminal2/Tabstops.zig rename src/{terminal/new => terminal2}/Terminal.zig (99%) create mode 100644 src/terminal2/ansi.zig rename src/{terminal/new => terminal2}/bitmap_allocator.zig (100%) create mode 100644 src/terminal2/charsets.zig create mode 100644 src/terminal2/color.zig create mode 100644 src/terminal2/csi.zig rename src/{terminal/new => terminal2}/hash_map.zig (100%) create mode 100644 src/terminal2/kitty.zig rename src/{terminal/new => terminal2}/main.zig (100%) create mode 100644 src/terminal2/modes.zig create mode 100644 src/terminal2/mouse_shape.zig rename src/{terminal/new => terminal2}/page.zig (99%) rename src/{terminal/new => terminal2}/point.zig (95%) create mode 100644 src/terminal2/res/rgb.txt create mode 100644 src/terminal2/sgr.zig rename src/{terminal/new => terminal2}/size.zig (100%) rename src/{terminal/new => terminal2}/style.zig (99%) create mode 100644 src/terminal2/x11_color.zig diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 8af4ffff3f..3e6262014e 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -15,7 +15,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); const terminal = @import("../terminal/main.zig"); -const terminalnew = @import("../terminal/new/main.zig"); +const terminalnew = @import("../terminal2/main.zig"); const Args = struct { mode: Mode = .noop, diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 73e771a7cb..4e99001c67 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -309,6 +309,7 @@ test { _ = @import("segmented_pool.zig"); _ = @import("inspector/main.zig"); _ = @import("terminal/main.zig"); + _ = @import("terminal2/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); _ = @import("unicode/main.zig"); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 55625f7a69..5c2a64e207 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -25,7 +25,6 @@ pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; pub const Terminal = @import("Terminal.zig"); -//pub const Terminal = new.Terminal; pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); pub const Screen = @import("Screen.zig"); @@ -49,8 +48,8 @@ pub usingnamespace if (builtin.target.isWasm()) struct { pub usingnamespace @import("wasm.zig"); } else struct {}; -/// The new stuff. TODO: remove this before merge. -pub const new = @import("new/main.zig"); +// TODO(paged-terminal) remove before merge +pub const new = @import("../terminal2/main.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/new/PageList.zig b/src/terminal2/PageList.zig similarity index 100% rename from src/terminal/new/PageList.zig rename to src/terminal2/PageList.zig diff --git a/src/terminal/new/Screen.zig b/src/terminal2/Screen.zig similarity index 99% rename from src/terminal/new/Screen.zig rename to src/terminal2/Screen.zig index f4581b722a..5326fb7e83 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal2/Screen.zig @@ -3,12 +3,12 @@ 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 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"); @@ -37,7 +37,8 @@ cursor: Cursor, saved_cursor: ?SavedCursor = null, /// The selection for this screen (if any). -selection: ?Selection = null, +//selection: ?Selection = null, +selection: ?void = null, /// The charset state charset: CharsetState = .{}, @@ -826,18 +827,18 @@ pub fn manualStyleUpdate(self: *Screen) !void { /// 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 { - _ = self; - _ = alloc; - _ = sel; - _ = trim; - @panic("TODO"); -} +// pub fn selectionString( +// self: *Screen, +// alloc: Allocator, +// sel: Selection, +// trim: bool, +// ) ![:0]const u8 { +// _ = self; +// _ = alloc; +// _ = sel; +// _ = trim; +// @panic("TODO"); +// } /// Dump the screen to a string. The writer given should be buffered; /// this function does not attempt to efficiently write and generally writes diff --git a/src/terminal2/Tabstops.zig b/src/terminal2/Tabstops.zig new file mode 100644 index 0000000000..5a54fb28b8 --- /dev/null +++ b/src/terminal2/Tabstops.zig @@ -0,0 +1,231 @@ +//! Keep track of the location of tabstops. +//! +//! This is implemented as a bit set. There is a preallocation segment that +//! is used for almost all screen sizes. Then there is a dynamically allocated +//! segment if the screen is larger than the preallocation amount. +//! +//! In reality, tabstops don't need to be the most performant in any metric. +//! This implementation tries to balance denser memory usage (by using a bitset) +//! and minimizing unnecessary allocations. +const Tabstops = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const testing = std.testing; +const assert = std.debug.assert; +const fastmem = @import("../fastmem.zig"); + +/// Unit is the type we use per tabstop unit (see file docs). +const Unit = u8; +const unit_bits = @bitSizeOf(Unit); + +/// The number of columns we preallocate for. This is kind of high which +/// costs us some memory, but this is more columns than my 6k monitor at +/// 12-point font size, so this should prevent allocation in almost all +/// real world scenarios for the price of wasting at most +/// (columns / sizeOf(Unit)) bytes. +const prealloc_columns = 512; + +/// The number of entries we need for our preallocation. +const prealloc_count = prealloc_columns / unit_bits; + +/// We precompute all the possible masks since we never use a huge bit size. +const masks = blk: { + var res: [unit_bits]Unit = undefined; + for (res, 0..) |_, i| { + res[i] = @shlExact(@as(Unit, 1), @as(u3, @intCast(i))); + } + + break :blk res; +}; + +/// The number of columns this tabstop is set to manage. Use resize() +/// to change this number. +cols: usize = 0, + +/// Preallocated tab stops. +prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count, + +/// Dynamically expanded stops above prealloc stops. +dynamic_stops: []Unit = &[0]Unit{}, + +/// Returns the entry in the stops array that would contain this column. +inline fn entry(col: usize) usize { + return col / unit_bits; +} + +inline fn index(col: usize) usize { + return @mod(col, unit_bits); +} + +pub fn init(alloc: Allocator, cols: usize, interval: usize) !Tabstops { + var res: Tabstops = .{}; + try res.resize(alloc, cols); + res.reset(interval); + return res; +} + +pub fn deinit(self: *Tabstops, alloc: Allocator) void { + if (self.dynamic_stops.len > 0) alloc.free(self.dynamic_stops); + self.* = undefined; +} + +/// Set the tabstop at a certain column. The columns are 0-indexed. +pub fn set(self: *Tabstops, col: usize) void { + const i = entry(col); + const idx = index(col); + if (i < prealloc_count) { + self.prealloc_stops[i] |= masks[idx]; + return; + } + + const dynamic_i = i - prealloc_count; + assert(dynamic_i < self.dynamic_stops.len); + self.dynamic_stops[dynamic_i] |= masks[idx]; +} + +/// Unset the tabstop at a certain column. The columns are 0-indexed. +pub fn unset(self: *Tabstops, col: usize) void { + const i = entry(col); + const idx = index(col); + if (i < prealloc_count) { + self.prealloc_stops[i] ^= masks[idx]; + return; + } + + const dynamic_i = i - prealloc_count; + assert(dynamic_i < self.dynamic_stops.len); + self.dynamic_stops[dynamic_i] ^= masks[idx]; +} + +/// Get the value of a tabstop at a specific column. The columns are 0-indexed. +pub fn get(self: Tabstops, col: usize) bool { + const i = entry(col); + const idx = index(col); + const mask = masks[idx]; + const unit = if (i < prealloc_count) + self.prealloc_stops[i] + else unit: { + const dynamic_i = i - prealloc_count; + assert(dynamic_i < self.dynamic_stops.len); + break :unit self.dynamic_stops[dynamic_i]; + }; + + return unit & mask == mask; +} + +/// Resize this to support up to cols columns. +// TODO: needs interval to set new tabstops +pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void { + // Set our new value + self.cols = cols; + + // Do nothing if it fits. + if (cols <= prealloc_columns) return; + + // What we need in the dynamic size + const size = cols - prealloc_columns; + if (size < self.dynamic_stops.len) return; + + // Note: we can probably try to realloc here but I'm not sure it matters. + const new = try alloc.alloc(Unit, size); + if (self.dynamic_stops.len > 0) { + fastmem.copy(Unit, new, self.dynamic_stops); + alloc.free(self.dynamic_stops); + } + + self.dynamic_stops = new; +} + +/// Return the maximum number of columns this can support currently. +pub fn capacity(self: Tabstops) usize { + return (prealloc_count + self.dynamic_stops.len) * unit_bits; +} + +/// Unset all tabstops and then reset the initial tabstops to the given +/// interval. An interval of 0 sets no tabstops. +pub fn reset(self: *Tabstops, interval: usize) void { + @memset(&self.prealloc_stops, 0); + @memset(self.dynamic_stops, 0); + + if (interval > 0) { + var i: usize = interval; + while (i < self.cols - 1) : (i += interval) { + self.set(i); + } + } +} + +test "Tabstops: basic" { + var t: Tabstops = .{}; + defer t.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), entry(4)); + try testing.expectEqual(@as(usize, 1), entry(8)); + try testing.expectEqual(@as(usize, 0), index(0)); + try testing.expectEqual(@as(usize, 1), index(1)); + try testing.expectEqual(@as(usize, 1), index(9)); + + try testing.expectEqual(@as(Unit, 0b00001000), masks[3]); + try testing.expectEqual(@as(Unit, 0b00010000), masks[4]); + + try testing.expect(!t.get(4)); + t.set(4); + try testing.expect(t.get(4)); + try testing.expect(!t.get(3)); + + t.reset(0); + try testing.expect(!t.get(4)); + + t.set(4); + try testing.expect(t.get(4)); + t.unset(4); + try testing.expect(!t.get(4)); +} + +test "Tabstops: dynamic allocations" { + var t: Tabstops = .{}; + defer t.deinit(testing.allocator); + + // Grow the capacity by 2. + const cap = t.capacity(); + try t.resize(testing.allocator, cap * 2); + + // Set something that was out of range of the first + t.set(cap + 5); + try testing.expect(t.get(cap + 5)); + try testing.expect(!t.get(cap + 4)); + + // Prealloc still works + try testing.expect(!t.get(5)); +} + +test "Tabstops: interval" { + var t: Tabstops = try init(testing.allocator, 80, 4); + defer t.deinit(testing.allocator); + try testing.expect(!t.get(0)); + try testing.expect(t.get(4)); + try testing.expect(!t.get(5)); + try testing.expect(t.get(8)); +} + +test "Tabstops: count on 80" { + // https://superuser.com/questions/710019/why-there-are-11-tabstops-on-a-80-column-console + + var t: Tabstops = try init(testing.allocator, 80, 8); + defer t.deinit(testing.allocator); + + // Count the tabstops + const count: usize = count: { + var v: usize = 0; + var i: usize = 0; + while (i < 80) : (i += 1) { + if (t.get(i)) { + v += 1; + } + } + + break :count v; + }; + + try testing.expectEqual(@as(usize, 9), count); +} diff --git a/src/terminal/new/Terminal.zig b/src/terminal2/Terminal.zig similarity index 99% rename from src/terminal/new/Terminal.zig rename to src/terminal2/Terminal.zig index 51f5ddf58b..8afa666f36 100644 --- a/src/terminal/new/Terminal.zig +++ b/src/terminal2/Terminal.zig @@ -12,17 +12,17 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; -const unicode = @import("../../unicode/main.zig"); - -const ansi = @import("../ansi.zig"); -const modes = @import("../modes.zig"); -const charsets = @import("../charsets.zig"); -const csi = @import("../csi.zig"); -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 unicode = @import("../unicode/main.zig"); + +const ansi = @import("ansi.zig"); +const modes = @import("modes.zig"); +const charsets = @import("charsets.zig"); +const csi = @import("csi.zig"); +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"); diff --git a/src/terminal2/ansi.zig b/src/terminal2/ansi.zig new file mode 100644 index 0000000000..43c2a9a1c2 --- /dev/null +++ b/src/terminal2/ansi.zig @@ -0,0 +1,114 @@ +/// C0 (7-bit) control characters from ANSI. +/// +/// This is not complete, control characters are only added to this +/// as the terminal emulator handles them. +pub const C0 = enum(u7) { + /// Null + NUL = 0x00, + /// Start of heading + SOH = 0x01, + /// Start of text + STX = 0x02, + /// Enquiry + ENQ = 0x05, + /// Bell + BEL = 0x07, + /// Backspace + BS = 0x08, + // Horizontal tab + HT = 0x09, + /// Line feed + LF = 0x0A, + /// Vertical Tab + VT = 0x0B, + /// Form feed + FF = 0x0C, + /// Carriage return + CR = 0x0D, + /// Shift out + SO = 0x0E, + /// Shift in + SI = 0x0F, + + // Non-exhaustive so that @intToEnum never fails since the inputs are + // user-generated. + _, +}; + +/// The SGR rendition aspects that can be set, sometimes known as attributes. +/// The value corresponds to the parameter value for the SGR command (ESC [ m). +pub const RenditionAspect = enum(u16) { + default = 0, + bold = 1, + default_fg = 39, + default_bg = 49, + + // Non-exhaustive so that @intToEnum never fails since the inputs are + // user-generated. + _, +}; + +/// The device attribute request type (ESC [ c). +pub const DeviceAttributeReq = enum { + primary, // Blank + secondary, // > + tertiary, // = +}; + +/// Possible cursor styles (ESC [ q) +pub const CursorStyle = enum(u16) { + default = 0, + blinking_block = 1, + steady_block = 2, + blinking_underline = 3, + steady_underline = 4, + blinking_bar = 5, + steady_bar = 6, + + // Non-exhaustive so that @intToEnum never fails for unsupported modes. + _, + + /// True if the cursor should blink. + pub fn blinking(self: CursorStyle) bool { + return switch (self) { + .blinking_block, .blinking_underline, .blinking_bar => true, + else => false, + }; + } +}; + +/// The status line type for DECSSDT. +pub const StatusLineType = enum(u16) { + none = 0, + indicator = 1, + host_writable = 2, + + // Non-exhaustive so that @intToEnum never fails for unsupported values. + _, +}; + +/// The display to target for status updates (DECSASD). +pub const StatusDisplay = enum(u16) { + main = 0, + status_line = 1, + + // Non-exhaustive so that @intToEnum never fails for unsupported values. + _, +}; + +/// The possible modify key formats to ESC[>{a};{b}m +/// Note: this is not complete, we should add more as we support more +pub const ModifyKeyFormat = union(enum) { + legacy: void, + cursor_keys: void, + function_keys: void, + other_keys: enum { none, numeric_except, numeric }, +}; + +/// The protection modes that can be set for the terminal. See DECSCA and +/// ESC V, W. +pub const ProtectedMode = enum { + off, + iso, // ESC V, W + dec, // CSI Ps " q +}; diff --git a/src/terminal/new/bitmap_allocator.zig b/src/terminal2/bitmap_allocator.zig similarity index 100% rename from src/terminal/new/bitmap_allocator.zig rename to src/terminal2/bitmap_allocator.zig diff --git a/src/terminal2/charsets.zig b/src/terminal2/charsets.zig new file mode 100644 index 0000000000..3162384581 --- /dev/null +++ b/src/terminal2/charsets.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const assert = std.debug.assert; + +/// The available charset slots for a terminal. +pub const Slots = enum(u3) { + G0 = 0, + G1 = 1, + G2 = 2, + G3 = 3, +}; + +/// The name of the active slots. +pub const ActiveSlot = enum { GL, GR }; + +/// The list of supported character sets and their associated tables. +pub const Charset = enum { + utf8, + ascii, + british, + dec_special, + + /// The table for the given charset. This returns a pointer to a + /// slice that is guaranteed to be 255 chars that can be used to map + /// ASCII to the given charset. + pub fn table(set: Charset) []const u16 { + return switch (set) { + .british => &british, + .dec_special => &dec_special, + + // utf8 is not a table, callers should double-check if the + // charset is utf8 and NOT use tables. + .utf8 => unreachable, + + // recommended that callers just map ascii directly but we can + // support a table + .ascii => &ascii, + }; + } +}; + +/// Just a basic c => c ascii table +const ascii = initTable(); + +/// https://vt100.net/docs/vt220-rm/chapter2.html +const british = british: { + var table = initTable(); + table[0x23] = 0x00a3; + break :british table; +}; + +/// https://en.wikipedia.org/wiki/DEC_Special_Graphics +const dec_special = tech: { + var table = initTable(); + table[0x60] = 0x25C6; + table[0x61] = 0x2592; + table[0x62] = 0x2409; + table[0x63] = 0x240C; + table[0x64] = 0x240D; + table[0x65] = 0x240A; + table[0x66] = 0x00B0; + table[0x67] = 0x00B1; + table[0x68] = 0x2424; + table[0x69] = 0x240B; + table[0x6a] = 0x2518; + table[0x6b] = 0x2510; + table[0x6c] = 0x250C; + table[0x6d] = 0x2514; + table[0x6e] = 0x253C; + table[0x6f] = 0x23BA; + table[0x70] = 0x23BB; + table[0x71] = 0x2500; + table[0x72] = 0x23BC; + table[0x73] = 0x23BD; + table[0x74] = 0x251C; + table[0x75] = 0x2524; + table[0x76] = 0x2534; + table[0x77] = 0x252C; + table[0x78] = 0x2502; + table[0x79] = 0x2264; + table[0x7a] = 0x2265; + table[0x7b] = 0x03C0; + table[0x7c] = 0x2260; + table[0x7d] = 0x00A3; + table[0x7e] = 0x00B7; + break :tech table; +}; + +/// Our table length is 256 so we can contain all ASCII chars. +const table_len = std.math.maxInt(u8) + 1; + +/// Creates a table that maps ASCII to ASCII as a getting started point. +fn initTable() [table_len]u16 { + var result: [table_len]u16 = undefined; + var i: usize = 0; + while (i < table_len) : (i += 1) result[i] = @intCast(i); + assert(i == table_len); + return result; +} + +test { + const testing = std.testing; + const info = @typeInfo(Charset).Enum; + inline for (info.fields) |field| { + // utf8 has no table + if (@field(Charset, field.name) == .utf8) continue; + + const table = @field(Charset, field.name).table(); + + // Yes, I could use `table_len` here, but I want to explicitly use a + // hardcoded constant so that if there are miscompilations or a comptime + // issue, we catch it. + try testing.expectEqual(@as(usize, 256), table.len); + } +} diff --git a/src/terminal2/color.zig b/src/terminal2/color.zig new file mode 100644 index 0000000000..194cee8b14 --- /dev/null +++ b/src/terminal2/color.zig @@ -0,0 +1,339 @@ +const std = @import("std"); +const assert = std.debug.assert; +const x11_color = @import("x11_color.zig"); + +/// The default palette. +pub const default: Palette = default: { + var result: Palette = undefined; + + // Named values + var i: u8 = 0; + while (i < 16) : (i += 1) { + result[i] = Name.default(@enumFromInt(i)) catch unreachable; + } + + // Cube + assert(i == 16); + var r: u8 = 0; + while (r < 6) : (r += 1) { + var g: u8 = 0; + while (g < 6) : (g += 1) { + var b: u8 = 0; + while (b < 6) : (b += 1) { + result[i] = .{ + .r = if (r == 0) 0 else (r * 40 + 55), + .g = if (g == 0) 0 else (g * 40 + 55), + .b = if (b == 0) 0 else (b * 40 + 55), + }; + + i += 1; + } + } + } + + // Grey ramp + assert(i == 232); + assert(@TypeOf(i) == u8); + while (i > 0) : (i +%= 1) { + const value = ((i - 232) * 10) + 8; + result[i] = .{ .r = value, .g = value, .b = value }; + } + + break :default result; +}; + +/// Palette is the 256 color palette. +pub const Palette = [256]RGB; + +/// Color names in the standard 8 or 16 color palette. +pub const Name = enum(u8) { + black = 0, + red = 1, + green = 2, + yellow = 3, + blue = 4, + magenta = 5, + cyan = 6, + white = 7, + + bright_black = 8, + bright_red = 9, + bright_green = 10, + bright_yellow = 11, + bright_blue = 12, + bright_magenta = 13, + bright_cyan = 14, + bright_white = 15, + + // Remainders are valid unnamed values in the 256 color palette. + _, + + /// Default colors for tagged values. + pub fn default(self: Name) !RGB { + return switch (self) { + .black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, + .red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 }, + .green => RGB{ .r = 0xB5, .g = 0xBD, .b = 0x68 }, + .yellow => RGB{ .r = 0xF0, .g = 0xC6, .b = 0x74 }, + .blue => RGB{ .r = 0x81, .g = 0xA2, .b = 0xBE }, + .magenta => RGB{ .r = 0xB2, .g = 0x94, .b = 0xBB }, + .cyan => RGB{ .r = 0x8A, .g = 0xBE, .b = 0xB7 }, + .white => RGB{ .r = 0xC5, .g = 0xC8, .b = 0xC6 }, + + .bright_black => RGB{ .r = 0x66, .g = 0x66, .b = 0x66 }, + .bright_red => RGB{ .r = 0xD5, .g = 0x4E, .b = 0x53 }, + .bright_green => RGB{ .r = 0xB9, .g = 0xCA, .b = 0x4A }, + .bright_yellow => RGB{ .r = 0xE7, .g = 0xC5, .b = 0x47 }, + .bright_blue => RGB{ .r = 0x7A, .g = 0xA6, .b = 0xDA }, + .bright_magenta => RGB{ .r = 0xC3, .g = 0x97, .b = 0xD8 }, + .bright_cyan => RGB{ .r = 0x70, .g = 0xC0, .b = 0xB1 }, + .bright_white => RGB{ .r = 0xEA, .g = 0xEA, .b = 0xEA }, + + else => error.NoDefaultValue, + }; + } +}; + +/// RGB +pub const RGB = struct { + r: u8 = 0, + g: u8 = 0, + b: u8 = 0, + + pub fn eql(self: RGB, other: RGB) bool { + return self.r == other.r and self.g == other.g and self.b == other.b; + } + + /// Calculates the contrast ratio between two colors. The contrast + /// ration is a value between 1 and 21 where 1 is the lowest contrast + /// and 21 is the highest contrast. + /// + /// https://www.w3.org/TR/WCAG20/#contrast-ratiodef + pub fn contrast(self: RGB, other: RGB) f64 { + // pair[0] = lighter, pair[1] = darker + const pair: [2]f64 = pair: { + const self_lum = self.luminance(); + const other_lum = other.luminance(); + if (self_lum > other_lum) break :pair .{ self_lum, other_lum }; + break :pair .{ other_lum, self_lum }; + }; + + return (pair[0] + 0.05) / (pair[1] + 0.05); + } + + /// Calculates luminance based on the W3C formula. This returns a + /// normalized value between 0 and 1 where 0 is black and 1 is white. + /// + /// https://www.w3.org/TR/WCAG20/#relativeluminancedef + pub fn luminance(self: RGB) f64 { + const r_lum = componentLuminance(self.r); + const g_lum = componentLuminance(self.g); + const b_lum = componentLuminance(self.b); + return 0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum; + } + + /// Calculates single-component luminance based on the W3C formula. + /// + /// Expects sRGB color space which at the time of writing we don't + /// generally use but it's a good enough approximation until we fix that. + /// https://www.w3.org/TR/WCAG20/#relativeluminancedef + fn componentLuminance(c: u8) f64 { + const c_f64: f64 = @floatFromInt(c); + const normalized: f64 = c_f64 / 255; + if (normalized <= 0.03928) return normalized / 12.92; + return std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + + /// Calculates "perceived luminance" which is better for determining + /// light vs dark. + /// + /// Source: https://www.w3.org/TR/AERT/#color-contrast + pub fn perceivedLuminance(self: RGB) f64 { + const r_f64: f64 = @floatFromInt(self.r); + const g_f64: f64 = @floatFromInt(self.g); + const b_f64: f64 = @floatFromInt(self.b); + return 0.299 * (r_f64 / 255) + 0.587 * (g_f64 / 255) + 0.114 * (b_f64 / 255); + } + + test "size" { + try std.testing.expectEqual(@as(usize, 24), @bitSizeOf(RGB)); + try std.testing.expectEqual(@as(usize, 3), @sizeOf(RGB)); + } + + /// Parse a color from a floating point intensity value. + /// + /// The value should be between 0.0 and 1.0, inclusive. + fn fromIntensity(value: []const u8) !u8 { + const i = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat; + if (i < 0.0 or i > 1.0) { + return error.InvalidFormat; + } + + return @intFromFloat(i * std.math.maxInt(u8)); + } + + /// Parse a color from a string of hexadecimal digits + /// + /// The string can contain 1, 2, 3, or 4 characters and represents the color + /// value scaled in 4, 8, 12, or 16 bits, respectively. + fn fromHex(value: []const u8) !u8 { + if (value.len == 0 or value.len > 4) { + return error.InvalidFormat; + } + + const color = std.fmt.parseUnsigned(u16, value, 16) catch return error.InvalidFormat; + const divisor: usize = switch (value.len) { + 1 => std.math.maxInt(u4), + 2 => std.math.maxInt(u8), + 3 => std.math.maxInt(u12), + 4 => std.math.maxInt(u16), + else => unreachable, + }; + + return @intCast(@as(usize, color) * std.math.maxInt(u8) / divisor); + } + + /// Parse a color specification. + /// + /// Any of the following forms are accepted: + /// + /// 1. rgb:// + /// + /// , , := h | hh | hhh | hhhh + /// + /// where `h` is a single hexadecimal digit. + /// + /// 2. rgbi:// + /// + /// where , , and are floating point values between + /// 0.0 and 1.0 (inclusive). + /// + /// 3. #hhhhhh + /// + /// where `h` is a single hexadecimal digit. + pub fn parse(value: []const u8) !RGB { + if (value.len == 0) { + return error.InvalidFormat; + } + + if (value[0] == '#') { + if (value.len != 7) { + return error.InvalidFormat; + } + + return RGB{ + .r = try RGB.fromHex(value[1..3]), + .g = try RGB.fromHex(value[3..5]), + .b = try RGB.fromHex(value[5..7]), + }; + } + + // Check for X11 named colors. We allow whitespace around the edges + // of the color because Kitty allows whitespace. This is not part of + // any spec I could find. + if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; + + if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { + return error.InvalidFormat; + } + + var i: usize = 3; + + const use_intensity = if (value[i] == 'i') blk: { + i += 1; + break :blk true; + } else false; + + if (value[i] != ':') { + return error.InvalidFormat; + } + + i += 1; + + const r = r: { + const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| + value[i..end] + else + return error.InvalidFormat; + + i += slice.len + 1; + + break :r if (use_intensity) + try RGB.fromIntensity(slice) + else + try RGB.fromHex(slice); + }; + + const g = g: { + const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| + value[i..end] + else + return error.InvalidFormat; + + i += slice.len + 1; + + break :g if (use_intensity) + try RGB.fromIntensity(slice) + else + try RGB.fromHex(slice); + }; + + const b = if (use_intensity) + try RGB.fromIntensity(value[i..]) + else + try RGB.fromHex(value[i..]); + + return RGB{ + .r = r, + .g = g, + .b = b, + }; + } +}; + +test "palette: default" { + const testing = std.testing; + + // Safety check + var i: u8 = 0; + while (i < 16) : (i += 1) { + try testing.expectEqual(Name.default(@as(Name, @enumFromInt(i))), default[i]); + } +} + +test "RGB.parse" { + const testing = std.testing; + + try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("rgbi:1.0/0/0")); + try testing.expectEqual(RGB{ .r = 127, .g = 160, .b = 0 }, try RGB.parse("rgb:7f/a0a0/0")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("rgb:f/ff/fff")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffff")); + try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010")); + + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black")); + try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red")); + try testing.expectEqual(RGB{ .r = 0, .g = 255, .b = 0 }, try RGB.parse("green")); + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 255 }, try RGB.parse("blue")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("white")); + + try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen")); + try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green ")); + + // Invalid format + try testing.expectError(error.InvalidFormat, RGB.parse("rgb;")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:")); + try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a")); + try testing.expectError(error.InvalidFormat, RGB.parse("a/a/a")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:a/a/a/")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:00000///")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:000/")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgbi:a/a/a")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:0.5/0.0/1.0")); + try testing.expectError(error.InvalidFormat, RGB.parse("rgb:not/hex/zz")); + try testing.expectError(error.InvalidFormat, RGB.parse("#")); + try testing.expectError(error.InvalidFormat, RGB.parse("#ff")); + try testing.expectError(error.InvalidFormat, RGB.parse("#ffff")); + try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); + try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); +} diff --git a/src/terminal2/csi.zig b/src/terminal2/csi.zig new file mode 100644 index 0000000000..877f5986e8 --- /dev/null +++ b/src/terminal2/csi.zig @@ -0,0 +1,33 @@ +// Modes for the ED CSI command. +pub const EraseDisplay = enum(u8) { + below = 0, + above = 1, + complete = 2, + scrollback = 3, + + /// This is an extension added by Kitty to move the viewport into the + /// scrollback and then erase the display. + scroll_complete = 22, +}; + +// Modes for the EL CSI command. +pub const EraseLine = enum(u8) { + right = 0, + left = 1, + complete = 2, + right_unless_pending_wrap = 4, + + // Non-exhaustive so that @intToEnum never fails since the inputs are + // user-generated. + _, +}; + +// Modes for the TBC (tab clear) command. +pub const TabClear = enum(u8) { + current = 0, + all = 3, + + // Non-exhaustive so that @intToEnum never fails since the inputs are + // user-generated. + _, +}; diff --git a/src/terminal/new/hash_map.zig b/src/terminal2/hash_map.zig similarity index 100% rename from src/terminal/new/hash_map.zig rename to src/terminal2/hash_map.zig diff --git a/src/terminal2/kitty.zig b/src/terminal2/kitty.zig new file mode 100644 index 0000000000..6b86a3280c --- /dev/null +++ b/src/terminal2/kitty.zig @@ -0,0 +1,9 @@ +//! Types and functions related to Kitty protocols. + +// TODO: migrate to terminal2 +pub const graphics = @import("../terminal/kitty/graphics.zig"); +pub usingnamespace @import("../terminal/kitty/key.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/new/main.zig b/src/terminal2/main.zig similarity index 100% rename from src/terminal/new/main.zig rename to src/terminal2/main.zig diff --git a/src/terminal2/modes.zig b/src/terminal2/modes.zig new file mode 100644 index 0000000000..e42efa16e7 --- /dev/null +++ b/src/terminal2/modes.zig @@ -0,0 +1,247 @@ +//! This file contains all the terminal modes that we support +//! and various support types for them: an enum of supported modes, +//! a packed struct to store mode values, a more generalized state +//! struct to store values plus handle save/restore, and much more. +//! +//! There is pretty heavy comptime usage and type generation here. +//! I don't love to have this sort of complexity but its a good way +//! to ensure all our various types and logic remain in sync. + +const std = @import("std"); +const testing = std.testing; + +/// A struct that maintains the state of all the settable modes. +pub const ModeState = struct { + /// The values of the current modes. + values: ModePacked = .{}, + + /// The saved values. We only allow saving each mode once. + /// This is in line with other terminals that implement XTSAVE + /// and XTRESTORE. We can improve this in the future if it becomes + /// a real-world issue but we need to be aware of a DoS vector. + saved: ModePacked = .{}, + + /// Set a mode to a value. + pub fn set(self: *ModeState, mode: Mode, value: bool) void { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + @field(self.values, entry.name) = value; + }, + } + } + + /// Get the value of a mode. + pub fn get(self: *ModeState, mode: Mode) bool { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + return @field(self.values, entry.name); + }, + } + } + + /// Save the state of the given mode. This can then be restored + /// with restore. This will only be accurate if the previous + /// mode was saved exactly once and not restored. Otherwise this + /// will just keep restoring the last stored value in memory. + pub fn save(self: *ModeState, mode: Mode) void { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + @field(self.saved, entry.name) = @field(self.values, entry.name); + }, + } + } + + /// See save. This will return the restored value. + pub fn restore(self: *ModeState, mode: Mode) bool { + switch (mode) { + inline else => |mode_comptime| { + const entry = comptime entryForMode(mode_comptime); + @field(self.values, entry.name) = @field(self.saved, entry.name); + return @field(self.values, entry.name); + }, + } + } + + test { + // We have this here so that we explicitly fail when we change the + // size of modes. The size of modes is NOT particularly important, + // we just want to be mentally aware when it happens. + try std.testing.expectEqual(8, @sizeOf(ModePacked)); + } +}; + +/// A packed struct of all the settable modes. This shouldn't +/// be used directly but rather through the ModeState struct. +pub const ModePacked = packed_struct: { + const StructField = std.builtin.Type.StructField; + var fields: [entries.len]StructField = undefined; + for (entries, 0..) |entry, i| { + fields[i] = .{ + .name = entry.name, + .type = bool, + .default_value = &entry.default, + .is_comptime = false, + .alignment = 0, + }; + } + + break :packed_struct @Type(.{ .Struct = .{ + .layout = .Packed, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + +/// An enum(u16) of the available modes. See entries for available values. +pub const Mode = mode_enum: { + const EnumField = std.builtin.Type.EnumField; + var fields: [entries.len]EnumField = undefined; + for (entries, 0..) |entry, i| { + fields[i] = .{ + .name = entry.name, + .value = @as(ModeTag.Backing, @bitCast(ModeTag{ + .value = entry.value, + .ansi = entry.ansi, + })), + }; + } + + break :mode_enum @Type(.{ .Enum = .{ + .tag_type = ModeTag.Backing, + .fields = &fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +/// The tag type for our enum is a u16 but we use a packed struct +/// in order to pack the ansi bit into the tag. +pub const ModeTag = packed struct(u16) { + pub const Backing = @typeInfo(@This()).Struct.backing_integer.?; + value: u15, + ansi: bool = false, + + test "order" { + const t: ModeTag = .{ .value = 1 }; + const int: Backing = @bitCast(t); + try std.testing.expectEqual(@as(Backing, 1), int); + } +}; + +pub fn modeFromInt(v: u16, ansi: bool) ?Mode { + inline for (entries) |entry| { + if (comptime !entry.disabled) { + if (entry.value == v and entry.ansi == ansi) { + const tag: ModeTag = .{ .ansi = ansi, .value = entry.value }; + const int: ModeTag.Backing = @bitCast(tag); + return @enumFromInt(int); + } + } + } + + return null; +} + +fn entryForMode(comptime mode: Mode) ModeEntry { + @setEvalBranchQuota(10_000); + const name = @tagName(mode); + for (entries) |entry| { + if (std.mem.eql(u8, entry.name, name)) return entry; + } + + unreachable; +} + +/// A single entry of a possible mode we support. This is used to +/// dynamically define the enum and other tables. +const ModeEntry = struct { + name: [:0]const u8, + value: comptime_int, + default: bool = false, + + /// True if this is an ANSI mode, false if its a DEC mode (?-prefixed). + ansi: bool = false, + + /// If true, this mode is disabled and Ghostty will not allow it to be + /// set or queried. The mode enum still has it, allowing Ghostty developers + /// to develop a mode without exposing it to real users. + disabled: bool = false, +}; + +/// The full list of available entries. For documentation see how +/// they're used within Ghostty or google their values. It is not +/// valuable to redocument them all here. +const entries: []const ModeEntry = &.{ + // ANSI + .{ .name = "disable_keyboard", .value = 2, .ansi = true }, // KAM + .{ .name = "insert", .value = 4, .ansi = true }, + .{ .name = "send_receive_mode", .value = 12, .ansi = true, .default = true }, // SRM + .{ .name = "linefeed", .value = 20, .ansi = true }, + + // DEC + .{ .name = "cursor_keys", .value = 1 }, // DECCKM + .{ .name = "132_column", .value = 3 }, + .{ .name = "slow_scroll", .value = 4 }, + .{ .name = "reverse_colors", .value = 5 }, + .{ .name = "origin", .value = 6 }, + .{ .name = "wraparound", .value = 7, .default = true }, + .{ .name = "autorepeat", .value = 8 }, + .{ .name = "mouse_event_x10", .value = 9 }, + .{ .name = "cursor_blinking", .value = 12 }, + .{ .name = "cursor_visible", .value = 25, .default = true }, + .{ .name = "enable_mode_3", .value = 40 }, + .{ .name = "reverse_wrap", .value = 45 }, + .{ .name = "keypad_keys", .value = 66 }, + .{ .name = "enable_left_and_right_margin", .value = 69 }, + .{ .name = "mouse_event_normal", .value = 1000 }, + .{ .name = "mouse_event_button", .value = 1002 }, + .{ .name = "mouse_event_any", .value = 1003 }, + .{ .name = "focus_event", .value = 1004 }, + .{ .name = "mouse_format_utf8", .value = 1005 }, + .{ .name = "mouse_format_sgr", .value = 1006 }, + .{ .name = "mouse_alternate_scroll", .value = 1007, .default = true }, + .{ .name = "mouse_format_urxvt", .value = 1015 }, + .{ .name = "mouse_format_sgr_pixels", .value = 1016 }, + .{ .name = "ignore_keypad_with_numlock", .value = 1035, .default = true }, + .{ .name = "alt_esc_prefix", .value = 1036, .default = true }, + .{ .name = "alt_sends_escape", .value = 1039 }, + .{ .name = "reverse_wrap_extended", .value = 1045 }, + .{ .name = "alt_screen", .value = 1047 }, + .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, + .{ .name = "bracketed_paste", .value = 2004 }, + .{ .name = "synchronized_output", .value = 2026 }, + .{ .name = "grapheme_cluster", .value = 2027 }, + .{ .name = "report_color_scheme", .value = 2031 }, +}; + +test { + _ = Mode; + _ = ModePacked; +} + +test modeFromInt { + try testing.expect(modeFromInt(4, true).? == .insert); + try testing.expect(modeFromInt(9, true) == null); + try testing.expect(modeFromInt(9, false).? == .mouse_event_x10); + try testing.expect(modeFromInt(14, true) == null); +} + +test ModeState { + var state: ModeState = .{}; + + // Normal set/get + try testing.expect(!state.get(.cursor_keys)); + state.set(.cursor_keys, true); + try testing.expect(state.get(.cursor_keys)); + + // Save/restore + state.save(.cursor_keys); + state.set(.cursor_keys, false); + try testing.expect(!state.get(.cursor_keys)); + try testing.expect(state.restore(.cursor_keys)); + try testing.expect(state.get(.cursor_keys)); +} diff --git a/src/terminal2/mouse_shape.zig b/src/terminal2/mouse_shape.zig new file mode 100644 index 0000000000..cf8f42c4b6 --- /dev/null +++ b/src/terminal2/mouse_shape.zig @@ -0,0 +1,115 @@ +const std = @import("std"); + +/// The possible cursor shapes. Not all app runtimes support these shapes. +/// The shapes are always based on the W3C supported cursor styles so we +/// can have a cross platform list. +// +// Must be kept in sync with ghostty_cursor_shape_e +pub const MouseShape = enum(c_int) { + default, + context_menu, + help, + pointer, + progress, + wait, + cell, + crosshair, + text, + vertical_text, + alias, + copy, + move, + no_drop, + not_allowed, + grab, + grabbing, + all_scroll, + col_resize, + row_resize, + n_resize, + e_resize, + s_resize, + w_resize, + ne_resize, + nw_resize, + se_resize, + sw_resize, + ew_resize, + ns_resize, + nesw_resize, + nwse_resize, + zoom_in, + zoom_out, + + /// Build cursor shape from string or null if its unknown. + pub fn fromString(v: []const u8) ?MouseShape { + return string_map.get(v); + } +}; + +const string_map = std.ComptimeStringMap(MouseShape, .{ + // W3C + .{ "default", .default }, + .{ "context-menu", .context_menu }, + .{ "help", .help }, + .{ "pointer", .pointer }, + .{ "progress", .progress }, + .{ "wait", .wait }, + .{ "cell", .cell }, + .{ "crosshair", .crosshair }, + .{ "text", .text }, + .{ "vertical-text", .vertical_text }, + .{ "alias", .alias }, + .{ "copy", .copy }, + .{ "move", .move }, + .{ "no-drop", .no_drop }, + .{ "not-allowed", .not_allowed }, + .{ "grab", .grab }, + .{ "grabbing", .grabbing }, + .{ "all-scroll", .all_scroll }, + .{ "col-resize", .col_resize }, + .{ "row-resize", .row_resize }, + .{ "n-resize", .n_resize }, + .{ "e-resize", .e_resize }, + .{ "s-resize", .s_resize }, + .{ "w-resize", .w_resize }, + .{ "ne-resize", .ne_resize }, + .{ "nw-resize", .nw_resize }, + .{ "se-resize", .se_resize }, + .{ "sw-resize", .sw_resize }, + .{ "ew-resize", .ew_resize }, + .{ "ns-resize", .ns_resize }, + .{ "nesw-resize", .nesw_resize }, + .{ "nwse-resize", .nwse_resize }, + .{ "zoom-in", .zoom_in }, + .{ "zoom-out", .zoom_out }, + + // xterm/foot + .{ "left_ptr", .default }, + .{ "question_arrow", .help }, + .{ "hand", .pointer }, + .{ "left_ptr_watch", .progress }, + .{ "watch", .wait }, + .{ "cross", .crosshair }, + .{ "xterm", .text }, + .{ "dnd-link", .alias }, + .{ "dnd-copy", .copy }, + .{ "dnd-move", .move }, + .{ "dnd-no-drop", .no_drop }, + .{ "crossed_circle", .not_allowed }, + .{ "hand1", .grab }, + .{ "right_side", .e_resize }, + .{ "top_side", .n_resize }, + .{ "top_right_corner", .ne_resize }, + .{ "top_left_corner", .nw_resize }, + .{ "bottom_side", .s_resize }, + .{ "bottom_right_corner", .se_resize }, + .{ "bottom_left_corner", .sw_resize }, + .{ "left_side", .w_resize }, + .{ "fleur", .all_scroll }, +}); + +test "cursor shape from string" { + const testing = std.testing; + try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); +} diff --git a/src/terminal/new/page.zig b/src/terminal2/page.zig similarity index 99% rename from src/terminal/new/page.zig rename to src/terminal2/page.zig index 045ad29af6..b9bd9b993c 100644 --- a/src/terminal/new/page.zig +++ b/src/terminal2/page.zig @@ -2,9 +2,9 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const testing = std.testing; -const fastmem = @import("../../fastmem.zig"); -const color = @import("../color.zig"); -const sgr = @import("../sgr.zig"); +const fastmem = @import("../fastmem.zig"); +const color = @import("color.zig"); +const sgr = @import("sgr.zig"); const style = @import("style.zig"); const size = @import("size.zig"); const getOffset = size.getOffset; diff --git a/src/terminal/new/point.zig b/src/terminal2/point.zig similarity index 95% rename from src/terminal/new/point.zig rename to src/terminal2/point.zig index 00350f2651..4f1d7836b8 100644 --- a/src/terminal/new/point.zig +++ b/src/terminal2/point.zig @@ -77,11 +77,6 @@ pub const Viewport = struct { pub fn eql(self: Viewport, other: Viewport) bool { return self.x == other.x and self.y == other.y; } - - const TerminalScreen = @import("Screen.zig"); - pub fn toScreen(_: Viewport, _: *const TerminalScreen) Screen { - @panic("TODO"); - } }; /// A point in the terminal that is in relation to the entire screen. diff --git a/src/terminal2/res/rgb.txt b/src/terminal2/res/rgb.txt new file mode 100644 index 0000000000..7096643760 --- /dev/null +++ b/src/terminal2/res/rgb.txt @@ -0,0 +1,782 @@ +255 250 250 snow +248 248 255 ghost white +248 248 255 GhostWhite +245 245 245 white smoke +245 245 245 WhiteSmoke +220 220 220 gainsboro +255 250 240 floral white +255 250 240 FloralWhite +253 245 230 old lace +253 245 230 OldLace +250 240 230 linen +250 235 215 antique white +250 235 215 AntiqueWhite +255 239 213 papaya whip +255 239 213 PapayaWhip +255 235 205 blanched almond +255 235 205 BlanchedAlmond +255 228 196 bisque +255 218 185 peach puff +255 218 185 PeachPuff +255 222 173 navajo white +255 222 173 NavajoWhite +255 228 181 moccasin +255 248 220 cornsilk +255 255 240 ivory +255 250 205 lemon chiffon +255 250 205 LemonChiffon +255 245 238 seashell +240 255 240 honeydew +245 255 250 mint cream +245 255 250 MintCream +240 255 255 azure +240 248 255 alice blue +240 248 255 AliceBlue +230 230 250 lavender +255 240 245 lavender blush +255 240 245 LavenderBlush +255 228 225 misty rose +255 228 225 MistyRose +255 255 255 white + 0 0 0 black + 47 79 79 dark slate gray + 47 79 79 DarkSlateGray + 47 79 79 dark slate grey + 47 79 79 DarkSlateGrey +105 105 105 dim gray +105 105 105 DimGray +105 105 105 dim grey +105 105 105 DimGrey +112 128 144 slate gray +112 128 144 SlateGray +112 128 144 slate grey +112 128 144 SlateGrey +119 136 153 light slate gray +119 136 153 LightSlateGray +119 136 153 light slate grey +119 136 153 LightSlateGrey +190 190 190 gray +190 190 190 grey +190 190 190 x11 gray +190 190 190 X11Gray +190 190 190 x11 grey +190 190 190 X11Grey +128 128 128 web gray +128 128 128 WebGray +128 128 128 web grey +128 128 128 WebGrey +211 211 211 light grey +211 211 211 LightGrey +211 211 211 light gray +211 211 211 LightGray + 25 25 112 midnight blue + 25 25 112 MidnightBlue + 0 0 128 navy + 0 0 128 navy blue + 0 0 128 NavyBlue +100 149 237 cornflower blue +100 149 237 CornflowerBlue + 72 61 139 dark slate blue + 72 61 139 DarkSlateBlue +106 90 205 slate blue +106 90 205 SlateBlue +123 104 238 medium slate blue +123 104 238 MediumSlateBlue +132 112 255 light slate blue +132 112 255 LightSlateBlue + 0 0 205 medium blue + 0 0 205 MediumBlue + 65 105 225 royal blue + 65 105 225 RoyalBlue + 0 0 255 blue + 30 144 255 dodger blue + 30 144 255 DodgerBlue + 0 191 255 deep sky blue + 0 191 255 DeepSkyBlue +135 206 235 sky blue +135 206 235 SkyBlue +135 206 250 light sky blue +135 206 250 LightSkyBlue + 70 130 180 steel blue + 70 130 180 SteelBlue +176 196 222 light steel blue +176 196 222 LightSteelBlue +173 216 230 light blue +173 216 230 LightBlue +176 224 230 powder blue +176 224 230 PowderBlue +175 238 238 pale turquoise +175 238 238 PaleTurquoise + 0 206 209 dark turquoise + 0 206 209 DarkTurquoise + 72 209 204 medium turquoise + 72 209 204 MediumTurquoise + 64 224 208 turquoise + 0 255 255 cyan + 0 255 255 aqua +224 255 255 light cyan +224 255 255 LightCyan + 95 158 160 cadet blue + 95 158 160 CadetBlue +102 205 170 medium aquamarine +102 205 170 MediumAquamarine +127 255 212 aquamarine + 0 100 0 dark green + 0 100 0 DarkGreen + 85 107 47 dark olive green + 85 107 47 DarkOliveGreen +143 188 143 dark sea green +143 188 143 DarkSeaGreen + 46 139 87 sea green + 46 139 87 SeaGreen + 60 179 113 medium sea green + 60 179 113 MediumSeaGreen + 32 178 170 light sea green + 32 178 170 LightSeaGreen +152 251 152 pale green +152 251 152 PaleGreen + 0 255 127 spring green + 0 255 127 SpringGreen +124 252 0 lawn green +124 252 0 LawnGreen + 0 255 0 green + 0 255 0 lime + 0 255 0 x11 green + 0 255 0 X11Green + 0 128 0 web green + 0 128 0 WebGreen +127 255 0 chartreuse + 0 250 154 medium spring green + 0 250 154 MediumSpringGreen +173 255 47 green yellow +173 255 47 GreenYellow + 50 205 50 lime green + 50 205 50 LimeGreen +154 205 50 yellow green +154 205 50 YellowGreen + 34 139 34 forest green + 34 139 34 ForestGreen +107 142 35 olive drab +107 142 35 OliveDrab +189 183 107 dark khaki +189 183 107 DarkKhaki +240 230 140 khaki +238 232 170 pale goldenrod +238 232 170 PaleGoldenrod +250 250 210 light goldenrod yellow +250 250 210 LightGoldenrodYellow +255 255 224 light yellow +255 255 224 LightYellow +255 255 0 yellow +255 215 0 gold +238 221 130 light goldenrod +238 221 130 LightGoldenrod +218 165 32 goldenrod +184 134 11 dark goldenrod +184 134 11 DarkGoldenrod +188 143 143 rosy brown +188 143 143 RosyBrown +205 92 92 indian red +205 92 92 IndianRed +139 69 19 saddle brown +139 69 19 SaddleBrown +160 82 45 sienna +205 133 63 peru +222 184 135 burlywood +245 245 220 beige +245 222 179 wheat +244 164 96 sandy brown +244 164 96 SandyBrown +210 180 140 tan +210 105 30 chocolate +178 34 34 firebrick +165 42 42 brown +233 150 122 dark salmon +233 150 122 DarkSalmon +250 128 114 salmon +255 160 122 light salmon +255 160 122 LightSalmon +255 165 0 orange +255 140 0 dark orange +255 140 0 DarkOrange +255 127 80 coral +240 128 128 light coral +240 128 128 LightCoral +255 99 71 tomato +255 69 0 orange red +255 69 0 OrangeRed +255 0 0 red +255 105 180 hot pink +255 105 180 HotPink +255 20 147 deep pink +255 20 147 DeepPink +255 192 203 pink +255 182 193 light pink +255 182 193 LightPink +219 112 147 pale violet red +219 112 147 PaleVioletRed +176 48 96 maroon +176 48 96 x11 maroon +176 48 96 X11Maroon +128 0 0 web maroon +128 0 0 WebMaroon +199 21 133 medium violet red +199 21 133 MediumVioletRed +208 32 144 violet red +208 32 144 VioletRed +255 0 255 magenta +255 0 255 fuchsia +238 130 238 violet +221 160 221 plum +218 112 214 orchid +186 85 211 medium orchid +186 85 211 MediumOrchid +153 50 204 dark orchid +153 50 204 DarkOrchid +148 0 211 dark violet +148 0 211 DarkViolet +138 43 226 blue violet +138 43 226 BlueViolet +160 32 240 purple +160 32 240 x11 purple +160 32 240 X11Purple +128 0 128 web purple +128 0 128 WebPurple +147 112 219 medium purple +147 112 219 MediumPurple +216 191 216 thistle +255 250 250 snow1 +238 233 233 snow2 +205 201 201 snow3 +139 137 137 snow4 +255 245 238 seashell1 +238 229 222 seashell2 +205 197 191 seashell3 +139 134 130 seashell4 +255 239 219 AntiqueWhite1 +238 223 204 AntiqueWhite2 +205 192 176 AntiqueWhite3 +139 131 120 AntiqueWhite4 +255 228 196 bisque1 +238 213 183 bisque2 +205 183 158 bisque3 +139 125 107 bisque4 +255 218 185 PeachPuff1 +238 203 173 PeachPuff2 +205 175 149 PeachPuff3 +139 119 101 PeachPuff4 +255 222 173 NavajoWhite1 +238 207 161 NavajoWhite2 +205 179 139 NavajoWhite3 +139 121 94 NavajoWhite4 +255 250 205 LemonChiffon1 +238 233 191 LemonChiffon2 +205 201 165 LemonChiffon3 +139 137 112 LemonChiffon4 +255 248 220 cornsilk1 +238 232 205 cornsilk2 +205 200 177 cornsilk3 +139 136 120 cornsilk4 +255 255 240 ivory1 +238 238 224 ivory2 +205 205 193 ivory3 +139 139 131 ivory4 +240 255 240 honeydew1 +224 238 224 honeydew2 +193 205 193 honeydew3 +131 139 131 honeydew4 +255 240 245 LavenderBlush1 +238 224 229 LavenderBlush2 +205 193 197 LavenderBlush3 +139 131 134 LavenderBlush4 +255 228 225 MistyRose1 +238 213 210 MistyRose2 +205 183 181 MistyRose3 +139 125 123 MistyRose4 +240 255 255 azure1 +224 238 238 azure2 +193 205 205 azure3 +131 139 139 azure4 +131 111 255 SlateBlue1 +122 103 238 SlateBlue2 +105 89 205 SlateBlue3 + 71 60 139 SlateBlue4 + 72 118 255 RoyalBlue1 + 67 110 238 RoyalBlue2 + 58 95 205 RoyalBlue3 + 39 64 139 RoyalBlue4 + 0 0 255 blue1 + 0 0 238 blue2 + 0 0 205 blue3 + 0 0 139 blue4 + 30 144 255 DodgerBlue1 + 28 134 238 DodgerBlue2 + 24 116 205 DodgerBlue3 + 16 78 139 DodgerBlue4 + 99 184 255 SteelBlue1 + 92 172 238 SteelBlue2 + 79 148 205 SteelBlue3 + 54 100 139 SteelBlue4 + 0 191 255 DeepSkyBlue1 + 0 178 238 DeepSkyBlue2 + 0 154 205 DeepSkyBlue3 + 0 104 139 DeepSkyBlue4 +135 206 255 SkyBlue1 +126 192 238 SkyBlue2 +108 166 205 SkyBlue3 + 74 112 139 SkyBlue4 +176 226 255 LightSkyBlue1 +164 211 238 LightSkyBlue2 +141 182 205 LightSkyBlue3 + 96 123 139 LightSkyBlue4 +198 226 255 SlateGray1 +185 211 238 SlateGray2 +159 182 205 SlateGray3 +108 123 139 SlateGray4 +202 225 255 LightSteelBlue1 +188 210 238 LightSteelBlue2 +162 181 205 LightSteelBlue3 +110 123 139 LightSteelBlue4 +191 239 255 LightBlue1 +178 223 238 LightBlue2 +154 192 205 LightBlue3 +104 131 139 LightBlue4 +224 255 255 LightCyan1 +209 238 238 LightCyan2 +180 205 205 LightCyan3 +122 139 139 LightCyan4 +187 255 255 PaleTurquoise1 +174 238 238 PaleTurquoise2 +150 205 205 PaleTurquoise3 +102 139 139 PaleTurquoise4 +152 245 255 CadetBlue1 +142 229 238 CadetBlue2 +122 197 205 CadetBlue3 + 83 134 139 CadetBlue4 + 0 245 255 turquoise1 + 0 229 238 turquoise2 + 0 197 205 turquoise3 + 0 134 139 turquoise4 + 0 255 255 cyan1 + 0 238 238 cyan2 + 0 205 205 cyan3 + 0 139 139 cyan4 +151 255 255 DarkSlateGray1 +141 238 238 DarkSlateGray2 +121 205 205 DarkSlateGray3 + 82 139 139 DarkSlateGray4 +127 255 212 aquamarine1 +118 238 198 aquamarine2 +102 205 170 aquamarine3 + 69 139 116 aquamarine4 +193 255 193 DarkSeaGreen1 +180 238 180 DarkSeaGreen2 +155 205 155 DarkSeaGreen3 +105 139 105 DarkSeaGreen4 + 84 255 159 SeaGreen1 + 78 238 148 SeaGreen2 + 67 205 128 SeaGreen3 + 46 139 87 SeaGreen4 +154 255 154 PaleGreen1 +144 238 144 PaleGreen2 +124 205 124 PaleGreen3 + 84 139 84 PaleGreen4 + 0 255 127 SpringGreen1 + 0 238 118 SpringGreen2 + 0 205 102 SpringGreen3 + 0 139 69 SpringGreen4 + 0 255 0 green1 + 0 238 0 green2 + 0 205 0 green3 + 0 139 0 green4 +127 255 0 chartreuse1 +118 238 0 chartreuse2 +102 205 0 chartreuse3 + 69 139 0 chartreuse4 +192 255 62 OliveDrab1 +179 238 58 OliveDrab2 +154 205 50 OliveDrab3 +105 139 34 OliveDrab4 +202 255 112 DarkOliveGreen1 +188 238 104 DarkOliveGreen2 +162 205 90 DarkOliveGreen3 +110 139 61 DarkOliveGreen4 +255 246 143 khaki1 +238 230 133 khaki2 +205 198 115 khaki3 +139 134 78 khaki4 +255 236 139 LightGoldenrod1 +238 220 130 LightGoldenrod2 +205 190 112 LightGoldenrod3 +139 129 76 LightGoldenrod4 +255 255 224 LightYellow1 +238 238 209 LightYellow2 +205 205 180 LightYellow3 +139 139 122 LightYellow4 +255 255 0 yellow1 +238 238 0 yellow2 +205 205 0 yellow3 +139 139 0 yellow4 +255 215 0 gold1 +238 201 0 gold2 +205 173 0 gold3 +139 117 0 gold4 +255 193 37 goldenrod1 +238 180 34 goldenrod2 +205 155 29 goldenrod3 +139 105 20 goldenrod4 +255 185 15 DarkGoldenrod1 +238 173 14 DarkGoldenrod2 +205 149 12 DarkGoldenrod3 +139 101 8 DarkGoldenrod4 +255 193 193 RosyBrown1 +238 180 180 RosyBrown2 +205 155 155 RosyBrown3 +139 105 105 RosyBrown4 +255 106 106 IndianRed1 +238 99 99 IndianRed2 +205 85 85 IndianRed3 +139 58 58 IndianRed4 +255 130 71 sienna1 +238 121 66 sienna2 +205 104 57 sienna3 +139 71 38 sienna4 +255 211 155 burlywood1 +238 197 145 burlywood2 +205 170 125 burlywood3 +139 115 85 burlywood4 +255 231 186 wheat1 +238 216 174 wheat2 +205 186 150 wheat3 +139 126 102 wheat4 +255 165 79 tan1 +238 154 73 tan2 +205 133 63 tan3 +139 90 43 tan4 +255 127 36 chocolate1 +238 118 33 chocolate2 +205 102 29 chocolate3 +139 69 19 chocolate4 +255 48 48 firebrick1 +238 44 44 firebrick2 +205 38 38 firebrick3 +139 26 26 firebrick4 +255 64 64 brown1 +238 59 59 brown2 +205 51 51 brown3 +139 35 35 brown4 +255 140 105 salmon1 +238 130 98 salmon2 +205 112 84 salmon3 +139 76 57 salmon4 +255 160 122 LightSalmon1 +238 149 114 LightSalmon2 +205 129 98 LightSalmon3 +139 87 66 LightSalmon4 +255 165 0 orange1 +238 154 0 orange2 +205 133 0 orange3 +139 90 0 orange4 +255 127 0 DarkOrange1 +238 118 0 DarkOrange2 +205 102 0 DarkOrange3 +139 69 0 DarkOrange4 +255 114 86 coral1 +238 106 80 coral2 +205 91 69 coral3 +139 62 47 coral4 +255 99 71 tomato1 +238 92 66 tomato2 +205 79 57 tomato3 +139 54 38 tomato4 +255 69 0 OrangeRed1 +238 64 0 OrangeRed2 +205 55 0 OrangeRed3 +139 37 0 OrangeRed4 +255 0 0 red1 +238 0 0 red2 +205 0 0 red3 +139 0 0 red4 +255 20 147 DeepPink1 +238 18 137 DeepPink2 +205 16 118 DeepPink3 +139 10 80 DeepPink4 +255 110 180 HotPink1 +238 106 167 HotPink2 +205 96 144 HotPink3 +139 58 98 HotPink4 +255 181 197 pink1 +238 169 184 pink2 +205 145 158 pink3 +139 99 108 pink4 +255 174 185 LightPink1 +238 162 173 LightPink2 +205 140 149 LightPink3 +139 95 101 LightPink4 +255 130 171 PaleVioletRed1 +238 121 159 PaleVioletRed2 +205 104 137 PaleVioletRed3 +139 71 93 PaleVioletRed4 +255 52 179 maroon1 +238 48 167 maroon2 +205 41 144 maroon3 +139 28 98 maroon4 +255 62 150 VioletRed1 +238 58 140 VioletRed2 +205 50 120 VioletRed3 +139 34 82 VioletRed4 +255 0 255 magenta1 +238 0 238 magenta2 +205 0 205 magenta3 +139 0 139 magenta4 +255 131 250 orchid1 +238 122 233 orchid2 +205 105 201 orchid3 +139 71 137 orchid4 +255 187 255 plum1 +238 174 238 plum2 +205 150 205 plum3 +139 102 139 plum4 +224 102 255 MediumOrchid1 +209 95 238 MediumOrchid2 +180 82 205 MediumOrchid3 +122 55 139 MediumOrchid4 +191 62 255 DarkOrchid1 +178 58 238 DarkOrchid2 +154 50 205 DarkOrchid3 +104 34 139 DarkOrchid4 +155 48 255 purple1 +145 44 238 purple2 +125 38 205 purple3 + 85 26 139 purple4 +171 130 255 MediumPurple1 +159 121 238 MediumPurple2 +137 104 205 MediumPurple3 + 93 71 139 MediumPurple4 +255 225 255 thistle1 +238 210 238 thistle2 +205 181 205 thistle3 +139 123 139 thistle4 + 0 0 0 gray0 + 0 0 0 grey0 + 3 3 3 gray1 + 3 3 3 grey1 + 5 5 5 gray2 + 5 5 5 grey2 + 8 8 8 gray3 + 8 8 8 grey3 + 10 10 10 gray4 + 10 10 10 grey4 + 13 13 13 gray5 + 13 13 13 grey5 + 15 15 15 gray6 + 15 15 15 grey6 + 18 18 18 gray7 + 18 18 18 grey7 + 20 20 20 gray8 + 20 20 20 grey8 + 23 23 23 gray9 + 23 23 23 grey9 + 26 26 26 gray10 + 26 26 26 grey10 + 28 28 28 gray11 + 28 28 28 grey11 + 31 31 31 gray12 + 31 31 31 grey12 + 33 33 33 gray13 + 33 33 33 grey13 + 36 36 36 gray14 + 36 36 36 grey14 + 38 38 38 gray15 + 38 38 38 grey15 + 41 41 41 gray16 + 41 41 41 grey16 + 43 43 43 gray17 + 43 43 43 grey17 + 46 46 46 gray18 + 46 46 46 grey18 + 48 48 48 gray19 + 48 48 48 grey19 + 51 51 51 gray20 + 51 51 51 grey20 + 54 54 54 gray21 + 54 54 54 grey21 + 56 56 56 gray22 + 56 56 56 grey22 + 59 59 59 gray23 + 59 59 59 grey23 + 61 61 61 gray24 + 61 61 61 grey24 + 64 64 64 gray25 + 64 64 64 grey25 + 66 66 66 gray26 + 66 66 66 grey26 + 69 69 69 gray27 + 69 69 69 grey27 + 71 71 71 gray28 + 71 71 71 grey28 + 74 74 74 gray29 + 74 74 74 grey29 + 77 77 77 gray30 + 77 77 77 grey30 + 79 79 79 gray31 + 79 79 79 grey31 + 82 82 82 gray32 + 82 82 82 grey32 + 84 84 84 gray33 + 84 84 84 grey33 + 87 87 87 gray34 + 87 87 87 grey34 + 89 89 89 gray35 + 89 89 89 grey35 + 92 92 92 gray36 + 92 92 92 grey36 + 94 94 94 gray37 + 94 94 94 grey37 + 97 97 97 gray38 + 97 97 97 grey38 + 99 99 99 gray39 + 99 99 99 grey39 +102 102 102 gray40 +102 102 102 grey40 +105 105 105 gray41 +105 105 105 grey41 +107 107 107 gray42 +107 107 107 grey42 +110 110 110 gray43 +110 110 110 grey43 +112 112 112 gray44 +112 112 112 grey44 +115 115 115 gray45 +115 115 115 grey45 +117 117 117 gray46 +117 117 117 grey46 +120 120 120 gray47 +120 120 120 grey47 +122 122 122 gray48 +122 122 122 grey48 +125 125 125 gray49 +125 125 125 grey49 +127 127 127 gray50 +127 127 127 grey50 +130 130 130 gray51 +130 130 130 grey51 +133 133 133 gray52 +133 133 133 grey52 +135 135 135 gray53 +135 135 135 grey53 +138 138 138 gray54 +138 138 138 grey54 +140 140 140 gray55 +140 140 140 grey55 +143 143 143 gray56 +143 143 143 grey56 +145 145 145 gray57 +145 145 145 grey57 +148 148 148 gray58 +148 148 148 grey58 +150 150 150 gray59 +150 150 150 grey59 +153 153 153 gray60 +153 153 153 grey60 +156 156 156 gray61 +156 156 156 grey61 +158 158 158 gray62 +158 158 158 grey62 +161 161 161 gray63 +161 161 161 grey63 +163 163 163 gray64 +163 163 163 grey64 +166 166 166 gray65 +166 166 166 grey65 +168 168 168 gray66 +168 168 168 grey66 +171 171 171 gray67 +171 171 171 grey67 +173 173 173 gray68 +173 173 173 grey68 +176 176 176 gray69 +176 176 176 grey69 +179 179 179 gray70 +179 179 179 grey70 +181 181 181 gray71 +181 181 181 grey71 +184 184 184 gray72 +184 184 184 grey72 +186 186 186 gray73 +186 186 186 grey73 +189 189 189 gray74 +189 189 189 grey74 +191 191 191 gray75 +191 191 191 grey75 +194 194 194 gray76 +194 194 194 grey76 +196 196 196 gray77 +196 196 196 grey77 +199 199 199 gray78 +199 199 199 grey78 +201 201 201 gray79 +201 201 201 grey79 +204 204 204 gray80 +204 204 204 grey80 +207 207 207 gray81 +207 207 207 grey81 +209 209 209 gray82 +209 209 209 grey82 +212 212 212 gray83 +212 212 212 grey83 +214 214 214 gray84 +214 214 214 grey84 +217 217 217 gray85 +217 217 217 grey85 +219 219 219 gray86 +219 219 219 grey86 +222 222 222 gray87 +222 222 222 grey87 +224 224 224 gray88 +224 224 224 grey88 +227 227 227 gray89 +227 227 227 grey89 +229 229 229 gray90 +229 229 229 grey90 +232 232 232 gray91 +232 232 232 grey91 +235 235 235 gray92 +235 235 235 grey92 +237 237 237 gray93 +237 237 237 grey93 +240 240 240 gray94 +240 240 240 grey94 +242 242 242 gray95 +242 242 242 grey95 +245 245 245 gray96 +245 245 245 grey96 +247 247 247 gray97 +247 247 247 grey97 +250 250 250 gray98 +250 250 250 grey98 +252 252 252 gray99 +252 252 252 grey99 +255 255 255 gray100 +255 255 255 grey100 +169 169 169 dark grey +169 169 169 DarkGrey +169 169 169 dark gray +169 169 169 DarkGray + 0 0 139 dark blue + 0 0 139 DarkBlue + 0 139 139 dark cyan + 0 139 139 DarkCyan +139 0 139 dark magenta +139 0 139 DarkMagenta +139 0 0 dark red +139 0 0 DarkRed +144 238 144 light green +144 238 144 LightGreen +220 20 60 crimson + 75 0 130 indigo +128 128 0 olive +102 51 153 rebecca purple +102 51 153 RebeccaPurple +192 192 192 silver + 0 128 128 teal diff --git a/src/terminal2/sgr.zig b/src/terminal2/sgr.zig new file mode 100644 index 0000000000..b23bd15140 --- /dev/null +++ b/src/terminal2/sgr.zig @@ -0,0 +1,559 @@ +//! SGR (Select Graphic Rendition) attrinvbute parsing and types. + +const std = @import("std"); +const testing = std.testing; +const color = @import("color.zig"); + +/// Attribute type for SGR +pub const Attribute = union(enum) { + /// Unset all attributes + unset: void, + + /// Unknown attribute, the raw CSI command parameters are here. + unknown: struct { + /// Full is the full SGR input. + full: []const u16, + + /// Partial is the remaining, where we got hung up. + partial: []const u16, + }, + + /// Bold the text. + bold: void, + reset_bold: void, + + /// Italic text. + italic: void, + reset_italic: void, + + /// Faint/dim text. + /// Note: reset faint is the same SGR code as reset bold + faint: void, + + /// Underline the text + underline: Underline, + reset_underline: void, + underline_color: color.RGB, + @"256_underline_color": u8, + reset_underline_color: void, + + /// Blink the text + blink: void, + reset_blink: void, + + /// Invert fg/bg colors. + inverse: void, + reset_inverse: void, + + /// Invisible + invisible: void, + reset_invisible: void, + + /// Strikethrough the text. + strikethrough: void, + reset_strikethrough: void, + + /// Set foreground color as RGB values. + direct_color_fg: color.RGB, + + /// Set background color as RGB values. + direct_color_bg: color.RGB, + + /// Set the background/foreground as a named color attribute. + @"8_bg": color.Name, + @"8_fg": color.Name, + + /// Reset the fg/bg to their default values. + reset_fg: void, + reset_bg: void, + + /// Set the background/foreground as a named bright color attribute. + @"8_bright_bg": color.Name, + @"8_bright_fg": color.Name, + + /// Set background color as 256-color palette. + @"256_bg": u8, + + /// Set foreground color as 256-color palette. + @"256_fg": u8, + + pub const Underline = enum(u3) { + none = 0, + single = 1, + double = 2, + curly = 3, + dotted = 4, + dashed = 5, + }; +}; + +/// Parser parses the attributes from a list of SGR parameters. +pub const Parser = struct { + params: []const u16, + idx: usize = 0, + + /// True if the separator is a colon + colon: bool = false, + + /// Next returns the next attribute or null if there are no more attributes. + pub fn next(self: *Parser) ?Attribute { + if (self.idx > self.params.len) return null; + + // Implicitly means unset + if (self.params.len == 0) { + self.idx += 1; + return Attribute{ .unset = {} }; + } + + const slice = self.params[self.idx..self.params.len]; + self.idx += 1; + + // Our last one will have an idx be the last value. + if (slice.len == 0) return null; + + switch (slice[0]) { + 0 => return Attribute{ .unset = {} }, + + 1 => return Attribute{ .bold = {} }, + + 2 => return Attribute{ .faint = {} }, + + 3 => return Attribute{ .italic = {} }, + + 4 => blk: { + if (self.colon) { + switch (slice.len) { + // 0 is unreachable because we're here and we read + // an element to get here. + 0 => unreachable, + + // 1 is possible if underline is the last element. + 1 => return Attribute{ .underline = .single }, + + // 2 means we have a specific underline style. + 2 => { + self.idx += 1; + switch (slice[1]) { + 0 => return Attribute{ .reset_underline = {} }, + 1 => return Attribute{ .underline = .single }, + 2 => return Attribute{ .underline = .double }, + 3 => return Attribute{ .underline = .curly }, + 4 => return Attribute{ .underline = .dotted }, + 5 => return Attribute{ .underline = .dashed }, + + // For unknown underline styles, just render + // a single underline. + else => return Attribute{ .underline = .single }, + } + }, + + // Colon-separated must only be 2. + else => break :blk, + } + } + + return Attribute{ .underline = .single }; + }, + + 5 => return Attribute{ .blink = {} }, + + 6 => return Attribute{ .blink = {} }, + + 7 => return Attribute{ .inverse = {} }, + + 8 => return Attribute{ .invisible = {} }, + + 9 => return Attribute{ .strikethrough = {} }, + + 22 => return Attribute{ .reset_bold = {} }, + + 23 => return Attribute{ .reset_italic = {} }, + + 24 => return Attribute{ .reset_underline = {} }, + + 25 => return Attribute{ .reset_blink = {} }, + + 27 => return Attribute{ .reset_inverse = {} }, + + 28 => return Attribute{ .reset_invisible = {} }, + + 29 => return Attribute{ .reset_strikethrough = {} }, + + 30...37 => return Attribute{ + .@"8_fg" = @enumFromInt(slice[0] - 30), + }, + + 38 => if (slice.len >= 5 and slice[1] == 2) { + self.idx += 4; + + // In the 6-len form, ignore the 3rd param. + const rgb = slice[2..5]; + + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .direct_color_fg = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + } else if (slice.len >= 3 and slice[1] == 5) { + self.idx += 2; + return Attribute{ + .@"256_fg" = @truncate(slice[2]), + }; + }, + + 39 => return Attribute{ .reset_fg = {} }, + + 40...47 => return Attribute{ + .@"8_bg" = @enumFromInt(slice[0] - 40), + }, + + 48 => if (slice.len >= 5 and slice[1] == 2) { + self.idx += 4; + + // We only support the 5-len form. + const rgb = slice[2..5]; + + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .direct_color_bg = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + } else if (slice.len >= 3 and slice[1] == 5) { + self.idx += 2; + return Attribute{ + .@"256_bg" = @truncate(slice[2]), + }; + }, + + 49 => return Attribute{ .reset_bg = {} }, + + 58 => if (slice.len >= 5 and slice[1] == 2) { + self.idx += 4; + + // In the 6-len form, ignore the 3rd param. Otherwise, use it. + const rgb = if (slice.len == 5) slice[2..5] else rgb: { + // Consume one more element + self.idx += 1; + break :rgb slice[3..6]; + }; + + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .underline_color = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + } else if (slice.len >= 3 and slice[1] == 5) { + self.idx += 2; + return Attribute{ + .@"256_underline_color" = @truncate(slice[2]), + }; + }, + + 59 => return Attribute{ .reset_underline_color = {} }, + + 90...97 => return Attribute{ + // 82 instead of 90 to offset to "bright" colors + .@"8_bright_fg" = @enumFromInt(slice[0] - 82), + }, + + 100...107 => return Attribute{ + .@"8_bright_bg" = @enumFromInt(slice[0] - 92), + }, + + else => {}, + } + + return Attribute{ .unknown = .{ .full = self.params, .partial = slice } }; + } +}; + +fn testParse(params: []const u16) Attribute { + var p: Parser = .{ .params = params }; + return p.next().?; +} + +fn testParseColon(params: []const u16) Attribute { + var p: Parser = .{ .params = params, .colon = true }; + return p.next().?; +} + +test "sgr: Parser" { + try testing.expect(testParse(&[_]u16{}) == .unset); + try testing.expect(testParse(&[_]u16{0}) == .unset); + + { + const v = testParse(&[_]u16{ 38, 2, 40, 44, 52 }); + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 40), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 44), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 52), v.direct_color_fg.b); + } + + try testing.expect(testParse(&[_]u16{ 38, 2, 44, 52 }) == .unknown); + + { + const v = testParse(&[_]u16{ 48, 2, 40, 44, 52 }); + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 40), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 44), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 52), v.direct_color_bg.b); + } + + try testing.expect(testParse(&[_]u16{ 48, 2, 44, 52 }) == .unknown); +} + +test "sgr: Parser multiple" { + var p: Parser = .{ .params = &[_]u16{ 0, 38, 2, 40, 44, 52 } }; + try testing.expect(p.next().? == .unset); + try testing.expect(p.next().? == .direct_color_fg); + try testing.expect(p.next() == null); + try testing.expect(p.next() == null); +} + +test "sgr: bold" { + { + const v = testParse(&[_]u16{1}); + try testing.expect(v == .bold); + } + + { + const v = testParse(&[_]u16{22}); + try testing.expect(v == .reset_bold); + } +} + +test "sgr: italic" { + { + const v = testParse(&[_]u16{3}); + try testing.expect(v == .italic); + } + + { + const v = testParse(&[_]u16{23}); + try testing.expect(v == .reset_italic); + } +} + +test "sgr: underline" { + { + const v = testParse(&[_]u16{4}); + try testing.expect(v == .underline); + } + + { + const v = testParse(&[_]u16{24}); + try testing.expect(v == .reset_underline); + } +} + +test "sgr: underline styles" { + { + const v = testParseColon(&[_]u16{ 4, 2 }); + try testing.expect(v == .underline); + try testing.expect(v.underline == .double); + } + + { + const v = testParseColon(&[_]u16{ 4, 0 }); + try testing.expect(v == .reset_underline); + } + + { + const v = testParseColon(&[_]u16{ 4, 1 }); + try testing.expect(v == .underline); + try testing.expect(v.underline == .single); + } + + { + const v = testParseColon(&[_]u16{ 4, 3 }); + try testing.expect(v == .underline); + try testing.expect(v.underline == .curly); + } + + { + const v = testParseColon(&[_]u16{ 4, 4 }); + try testing.expect(v == .underline); + try testing.expect(v.underline == .dotted); + } + + { + const v = testParseColon(&[_]u16{ 4, 5 }); + try testing.expect(v == .underline); + try testing.expect(v.underline == .dashed); + } +} + +test "sgr: blink" { + { + const v = testParse(&[_]u16{5}); + try testing.expect(v == .blink); + } + + { + const v = testParse(&[_]u16{6}); + try testing.expect(v == .blink); + } + + { + const v = testParse(&[_]u16{25}); + try testing.expect(v == .reset_blink); + } +} + +test "sgr: inverse" { + { + const v = testParse(&[_]u16{7}); + try testing.expect(v == .inverse); + } + + { + const v = testParse(&[_]u16{27}); + try testing.expect(v == .reset_inverse); + } +} + +test "sgr: strikethrough" { + { + const v = testParse(&[_]u16{9}); + try testing.expect(v == .strikethrough); + } + + { + const v = testParse(&[_]u16{29}); + try testing.expect(v == .reset_strikethrough); + } +} + +test "sgr: 8 color" { + var p: Parser = .{ .params = &[_]u16{ 31, 43, 90, 103 } }; + + { + const v = p.next().?; + try testing.expect(v == .@"8_fg"); + try testing.expect(v.@"8_fg" == .red); + } + + { + const v = p.next().?; + try testing.expect(v == .@"8_bg"); + try testing.expect(v.@"8_bg" == .yellow); + } + + { + const v = p.next().?; + try testing.expect(v == .@"8_bright_fg"); + try testing.expect(v.@"8_bright_fg" == .bright_black); + } + + { + const v = p.next().?; + try testing.expect(v == .@"8_bright_bg"); + try testing.expect(v.@"8_bright_bg" == .bright_yellow); + } +} + +test "sgr: 256 color" { + var p: Parser = .{ .params = &[_]u16{ 38, 5, 161, 48, 5, 236 } }; + try testing.expect(p.next().? == .@"256_fg"); + try testing.expect(p.next().? == .@"256_bg"); + try testing.expect(p.next() == null); +} + +test "sgr: 256 color underline" { + var p: Parser = .{ .params = &[_]u16{ 58, 5, 9 } }; + try testing.expect(p.next().? == .@"256_underline_color"); + try testing.expect(p.next() == null); +} + +test "sgr: 24-bit bg color" { + { + const v = testParseColon(&[_]u16{ 48, 2, 1, 2, 3 }); + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b); + } +} + +test "sgr: underline color" { + { + const v = testParseColon(&[_]u16{ 58, 2, 1, 2, 3 }); + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 1), v.underline_color.r); + try testing.expectEqual(@as(u8, 2), v.underline_color.g); + try testing.expectEqual(@as(u8, 3), v.underline_color.b); + } + + { + const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 }); + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 1), v.underline_color.r); + try testing.expectEqual(@as(u8, 2), v.underline_color.g); + try testing.expectEqual(@as(u8, 3), v.underline_color.b); + } +} + +test "sgr: reset underline color" { + var p: Parser = .{ .params = &[_]u16{59} }; + try testing.expect(p.next().? == .reset_underline_color); +} + +test "sgr: invisible" { + var p: Parser = .{ .params = &[_]u16{ 8, 28 } }; + try testing.expect(p.next().? == .invisible); + try testing.expect(p.next().? == .reset_invisible); +} + +test "sgr: underline, bg, and fg" { + var p: Parser = .{ + .params = &[_]u16{ 4, 38, 2, 255, 247, 219, 48, 2, 242, 93, 147, 4 }, + }; + { + const v = p.next().?; + try testing.expect(v == .underline); + try testing.expectEqual(Attribute.Underline.single, v.underline); + } + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 255), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 247), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 219), v.direct_color_fg.b); + } + { + const v = p.next().?; + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 242), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 93), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 147), v.direct_color_bg.b); + } + { + const v = p.next().?; + try testing.expect(v == .underline); + try testing.expectEqual(Attribute.Underline.single, v.underline); + } +} + +test "sgr: direct color fg missing color" { + // This used to crash + var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false }; + while (p.next()) |_| {} +} + +test "sgr: direct color bg missing color" { + // This used to crash + var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; + while (p.next()) |_| {} +} diff --git a/src/terminal/new/size.zig b/src/terminal2/size.zig similarity index 100% rename from src/terminal/new/size.zig rename to src/terminal2/size.zig diff --git a/src/terminal/new/style.zig b/src/terminal2/style.zig similarity index 99% rename from src/terminal/new/style.zig rename to src/terminal2/style.zig index 56c2e936ab..e486307118 100644 --- a/src/terminal/new/style.zig +++ b/src/terminal2/style.zig @@ -1,7 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; -const color = @import("../color.zig"); -const sgr = @import("../sgr.zig"); +const color = @import("color.zig"); +const sgr = @import("sgr.zig"); const page = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; diff --git a/src/terminal2/x11_color.zig b/src/terminal2/x11_color.zig new file mode 100644 index 0000000000..9e4eda86bd --- /dev/null +++ b/src/terminal2/x11_color.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const assert = std.debug.assert; +const RGB = @import("color.zig").RGB; + +/// The map of all available X11 colors. +pub const map = colorMap() catch @compileError("failed to parse rgb.txt"); + +fn colorMap() !type { + @setEvalBranchQuota(100_000); + + const KV = struct { []const u8, RGB }; + + // The length of our data is the number of lines in the rgb file. + const len = std.mem.count(u8, data, "\n"); + var kvs: [len]KV = undefined; + + // Parse the line. This is not very robust parsing, because we expect + // a very exact format for rgb.txt. However, this is all done at comptime + // so if our data is bad, we should hopefully get an error here or one + // of our unit tests will catch it. + var iter = std.mem.splitScalar(u8, data, '\n'); + var i: usize = 0; + while (iter.next()) |line| { + if (line.len == 0) continue; + const r = try std.fmt.parseInt(u8, std.mem.trim(u8, line[0..3], " "), 10); + const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10); + const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10); + const name = std.mem.trim(u8, line[12..], " \t\n"); + kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } }; + i += 1; + } + assert(i == len); + + return std.ComptimeStringMapWithEql( + RGB, + kvs, + std.comptime_string_map.eqlAsciiIgnoreCase, + ); +} + +/// This is the rgb.txt file from the X11 project. This was last sourced +/// from this location: https://gitlab.freedesktop.org/xorg/app/rgb +/// This data is licensed under the MIT/X11 license while this Zig file is +/// licensed under the same license as Ghostty. +const data = @embedFile("res/rgb.txt"); + +test { + const testing = std.testing; + try testing.expectEqual(null, map.get("nosuchcolor")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, map.get("white").?); + try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("medium spring green")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("ForestGreen")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("FoReStGReen")); + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, map.get("black")); + try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, map.get("red")); + try testing.expectEqual(RGB{ .r = 0, .g = 255, .b = 0 }, map.get("green")); + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 255 }, map.get("blue")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, map.get("white")); + try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, map.get("lawngreen")); + try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen")); +} From fb1a64b6a980f84a4f62205a65158774a0652584 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 09:52:41 -0800 Subject: [PATCH 174/428] terminal2: working on pins and tracked pins --- src/terminal2/PageList.zig | 327 +++++++++++++++++++++++++++++++++++-- 1 file changed, 313 insertions(+), 14 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index a496815357..147af97206 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -43,11 +43,18 @@ const PagePool = std.heap.MemoryPoolAligned( std.mem.page_size, ); +/// List of pins, known as "tracked" pins. These are pins that are kept +/// up to date automatically through page-modifying operations. +const PinSet = std.AutoHashMapUnmanaged(*Pin, void); +const PinPool = std.heap.MemoryPool(Pin); + /// The pool of memory used for a pagelist. This can be shared between /// multiple pagelists but it is not threadsafe. pub const MemoryPool = struct { + alloc: Allocator, nodes: NodePool, pages: PagePool, + pins: PinPool, pub const ResetMode = std.heap.ArenaAllocator.ResetMode; @@ -60,17 +67,26 @@ pub const MemoryPool = struct { errdefer pool.deinit(); var page_pool = try PagePool.initPreheated(page_alloc, preheat); errdefer page_pool.deinit(); - return .{ .nodes = pool, .pages = page_pool }; + var pin_pool = try PinPool.initPreheated(gen_alloc, 8); + errdefer pin_pool.deinit(); + return .{ + .alloc = gen_alloc, + .nodes = pool, + .pages = page_pool, + .pins = pin_pool, + }; } pub fn deinit(self: *MemoryPool) void { self.pages.deinit(); self.nodes.deinit(); + self.pins.deinit(); } pub fn reset(self: *MemoryPool, mode: ResetMode) void { _ = self.pages.reset(mode); _ = self.nodes.reset(mode); + _ = self.pins.reset(mode); } }; @@ -91,6 +107,9 @@ page_size: usize, /// in a page that also includes scrollback, then that page is not included. max_size: usize, +/// The list of tracked pins. These are kept up to date automatically. +tracked_pins: PinSet, + /// The top-left of certain parts of the screen that are frequently /// accessed so we don't have to traverse the linked list to find them. /// @@ -100,6 +119,11 @@ max_size: usize, /// viewport: Viewport, +/// The pin used for when the viewport scrolls. This is always pre-allocated +/// so that scrolling doesn't have a failable memory allocation. This should +/// never be access directly; use `viewport`. +viewport_pin: *Pin, + /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily /// reflow text. @@ -149,6 +173,7 @@ pub fn init( // and we'll split it thereafter if it gets too large and add more as // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); + errdefer pool.deinit(); var page = try pool.nodes.create(); const page_buf = try pool.pages.create(); @@ -191,13 +216,18 @@ pub fn init( .pages = page_list, .page_size = page_size, .max_size = max_size_actual, + .tracked_pins = .{}, .viewport = .{ .active = {} }, + .viewport_pin = try pool.pins.create(), }; } /// Deinit the pagelist. If you own the memory pool (used clonePool) then /// this will reset the pool and retain capacity. pub fn deinit(self: *PageList) void { + // Always deallocate our hashmap. + self.tracked_pins.deinit(self.pool.alloc); + // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. if (self.pool_owned) { @@ -290,6 +320,11 @@ pub fn clonePool( total_rows += len; } + // Our viewport pin is always undefined since our viewport in a clones + // goes back to the top + const viewport_pin = try pool.pins.create(); + errdefer pool.pins.destroy(viewport_pin); + var result: PageList = .{ .pool = pool.*, .pool_owned = false, @@ -298,7 +333,9 @@ pub fn clonePool( .max_size = self.max_size, .cols = self.cols, .rows = self.rows, + .tracked_pins = .{}, // TODO .viewport = .{ .top = {} }, + .viewport_pin = viewport_pin, }; // We always need to have enough rows for our viewport because this is @@ -1387,6 +1424,20 @@ pub fn eraseRows( // be written to again (its in the past) or it will grow and the // terminal erase will automatically erase the data. + // Update any tracked pins to shift their y. If it was in the erased + // row then we move it to the top of this page. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page) continue; + if (p.y >= chunk.end) { + p.y -= chunk.end; + } else { + p.y = 0; + p.x = 0; + } + } + // If our viewport is on this page and the offset is beyond // our new end, shift it. switch (self.viewport) { @@ -1434,8 +1485,21 @@ pub fn eraseRows( } /// Erase a single page, freeing all its resources. The page can be -/// anywhere in the linked list. +/// anywhere in the linked list but must NOT be the final page in the +/// entire list (i.e. must not make the list empty). fn erasePage(self: *PageList, page: *List.Node) void { + assert(page.next != null or page.prev != null); + + // Update any tracked pins to move to the next page. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + p.page = page.next orelse page.prev orelse unreachable; + p.y = 0; + p.x = 0; + } + // If our viewport is pinned to this page, then we need to update it. switch (self.viewport) { .top, .active => {}, @@ -1455,10 +1519,38 @@ fn erasePage(self: *PageList, page: *List.Node) void { self.destroyPage(page); } -/// Get the top-left of the screen for the given tag. -pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { - // TODO: assert the point is valid - return self.getTopLeft(pt).forward(pt.coord().y).?; +/// Returns the pin for the given point. The pin is NOT tracked so it +/// is only valid as long as the pagelist isn't modified. +pub fn pin(self: *const PageList, pt: point.Point) ?Pin { + var p = self.getTopLeft2(pt).down(pt.coord().y) orelse return null; + p.x = pt.coord().x; + return p; +} + +/// Convert the given pin to a tracked pin. A tracked pin will always be +/// automatically updated as the pagelist is modified. If the point the +/// pin points to is removed completely, the tracked pin will be updated +/// to the top-left of the screen. +pub fn trackPin(self: *PageList, p: Pin) !*Pin { + // TODO: assert pin is valid + + // Create our tracked pin + const tracked = try self.pool.pins.create(); + errdefer self.pool.pins.destroy(tracked); + tracked.* = p; + + // Add it to the tracked list + try self.tracked_pins.putNoClobber(self.pool.alloc, tracked, {}); + errdefer _ = self.tracked_pins.remove(tracked); + + return tracked; +} + +/// Untrack a previously tracked pin. This will deallocate the pin. +pub fn untrackPin(self: *PageList, p: *Pin) void { + if (self.tracked_pins.remove(p)) { + self.pool.pins.destroy(p); + } } /// Get the cell at the given point, or null if the cell does not @@ -1466,14 +1558,14 @@ pub fn rowOffset(self: *const PageList, pt: point.Point) RowOffset { /// /// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { - const row = self.getTopLeft(pt).forward(pt.coord().y) orelse return null; - const rac = row.page.data.getRowAndCell(pt.coord().x, row.row_offset); + const pt_pin = self.pin(pt) orelse return null; + const rac = pt_pin.page.data.getRowAndCell(pt_pin.x, pt_pin.y); return .{ - .page = row.page, + .page = pt_pin.page, .row = rac.row, .cell = rac.cell, - .row_idx = row.row_offset, - .col_idx = pt.coord().x, + .row_idx = pt_pin.y, + .col_idx = pt_pin.x, }; } @@ -1686,6 +1778,38 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { }; } +/// Get the top-left of the screen for the given tag. +fn getTopLeft2(self: *const PageList, tag: point.Tag) Pin { + return switch (tag) { + // The full screen or history is always just the first page. + .screen, .history => .{ .page = self.pages.first.? }, + + .viewport => switch (self.viewport) { + .active => self.getTopLeft2(.active), + .top => self.getTopLeft2(.screen), + .exact => |v| .{ .page = v.page, .y = v.row_offset }, + }, + + // The active area is calculated backwards from the last page. + // This makes getting the active top left slower but makes scrolling + // much faster because we don't need to update the top left. Under + // heavy load this makes a measurable difference. + .active => active: { + var page = self.pages.last.?; + var rem = self.rows; + while (rem > page.data.size.rows) { + rem -= page.data.size.rows; + page = page.prev.?; // assertion: we always have enough rows for active + } + + break :active .{ + .page = page, + .y = page.data.size.rows - rem, + }; + }, + }; +} + /// The total rows in the screen. This is the actual row count currently /// and not a capacity or maximum. /// @@ -1722,6 +1846,108 @@ fn growRows(self: *PageList, n: usize) !void { } } +/// Represents an exact x/y coordinate within the screen. This is called +/// a "pin" because it is a fixed point within the pagelist direct to +/// a specific page pointer and memory offset. The benefit is that this +/// point remains valid even through scrolling without any additional work. +/// +/// A downside is that the pin is only valid until the pagelist is modified +/// in a way that may invalid page pointers or shuffle rows, such as resizing, +/// erasing rows, etc. +/// +/// A pin can also be "tracked" which means that it will be updated as the +/// PageList is modified. +/// +/// The PageList maintains a list of active pin references and keeps them +/// all up to date as the pagelist is modified. This isn't cheap so callers +/// should limit the number of active pins as much as possible. +pub const Pin = struct { + page: *List.Node, + y: usize = 0, + x: usize = 0, + + /// Move the pin down a certain number of rows, or return null if + /// the pin goes beyond the end of the screen. + pub fn down(self: Pin, n: usize) ?Pin { + return switch (self.downOverflow(n)) { + .offset => |v| v, + .overflow => null, + }; + } + + /// Move the pin up a certain number of rows, or return null if + /// the pin goes beyond the start of the screen. + pub fn up(self: Pin, n: usize) ?Pin { + return switch (self.upOverflow(n)) { + .offset => |v| v, + .overflow => null, + }; + } + + /// Move the offset down n rows. If the offset goes beyond the + /// end of the screen, return the overflow amount. + fn downOverflow(self: Pin, n: usize) union(enum) { + offset: Pin, + overflow: struct { + end: Pin, + remaining: usize, + }, + } { + // Index fits within this page + const rows = self.page.data.size.rows - (self.y + 1); + if (n <= rows) return .{ .offset = .{ + .page = self.page, + .y = n + self.y, + } }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var n_left: usize = n - rows; + while (true) { + page = page.next orelse return .{ .overflow = .{ + .end = .{ .page = page, .y = page.data.size.rows - 1 }, + .remaining = n_left, + } }; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .y = n_left - 1, + } }; + n_left -= page.data.size.rows; + } + } + + /// Move the offset up n rows. If the offset goes beyond the + /// start of the screen, return the overflow amount. + fn upOverflow(self: Pin, n: usize) union(enum) { + offset: Pin, + overflow: struct { + end: Pin, + remaining: usize, + }, + } { + // Index fits within this page + if (n <= self.y) return .{ .offset = .{ + .page = self.page, + .y = self.y - n, + } }; + + // Need to traverse page links to find the page + var page: *List.Node = self.page; + var n_left: usize = n - self.y; + while (true) { + page = page.prev orelse return .{ .overflow = .{ + .end = .{ .page = page, .y = 0 }, + .remaining = n_left, + } }; + if (n_left <= page.data.size.rows) return .{ .offset = .{ + .page = page, + .y = page.data.size.rows - n_left, + } }; + n_left -= page.data.size.rows; + } + } +}; + /// Represents some y coordinate within the screen. Since pages can /// be split at any row boundary, getting some Y-coordinate within /// any part of the screen may map to a different page and row offset @@ -1876,10 +2102,11 @@ test "PageList" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); // Active area should be the top - try testing.expectEqual(RowOffset{ + try testing.expectEqual(Pin{ .page = s.pages.first.?, - .row_offset = 0, - }, s.getTopLeft(.active)); + .y = 0, + .x = 0, + }, s.getTopLeft2(.active)); } test "PageList active after grow" { @@ -2333,6 +2560,78 @@ test "PageList erase" { try testing.expectEqual(s.rows, s.totalRows()); } +test "PageList erase row with tracked pin resets to top-left" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + // Our total rows should be large + try testing.expect(s.totalRows() > s.rows); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .history = .{} }).?); + defer s.untrackPin(p); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 0), p.x); +} + +test "PageList erase row with tracked pin shifts" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .y = 4, .x = 2 } }).?); + defer s.untrackPin(p); + + // Erase only a few rows in our active + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 2), p.x); +} + +test "PageList erase row with tracked pin is erased" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .y = 2, .x = 2 } }).?); + defer s.untrackPin(p); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); + try testing.expectEqual(s.rows, s.totalRows()); + + // Our pin should move to the first page + try testing.expectEqual(s.pages.first.?, p.page); + try testing.expectEqual(@as(usize, 0), p.y); + try testing.expectEqual(@as(usize, 0), p.x); +} + test "PageList erase resets viewport to active if moves within active" { const testing = std.testing; const alloc = testing.allocator; From 2837a95d4bc1f7a13bad6e6e5908d5ff947ebf24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 10:08:50 -0800 Subject: [PATCH 175/428] terminal2: viewport exact is gone, now pin --- src/terminal2/PageList.zig | 131 +++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 71 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 147af97206..7132ff2c01 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -141,10 +141,10 @@ pub const Viewport = union(enum) { /// back in the scrollback history. top, - /// The viewport is pinned to an exact row offset. If this page is - /// deleted (i.e. due to pruning scrollback), then the viewport will - /// stick to the top. - exact: RowOffset, + /// The viewport is pinned to a tracked pin. The tracked pin is ALWAYS + /// s.viewport_pin hence this has no value. We force that value to prevent + /// allocations. + pin, }; /// Initialize the page. The top of the first page in the list is always the @@ -208,6 +208,12 @@ pub fn init( PagePool.item_size * 2, ); + // We always track our viewport pin to ensure this is never an allocation + const viewport_pin = try pool.pins.create(); + var tracked_pins: PinSet = .{}; + errdefer tracked_pins.deinit(pool.alloc); + try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); + return .{ .cols = cols, .rows = rows, @@ -216,9 +222,9 @@ pub fn init( .pages = page_list, .page_size = page_size, .max_size = max_size_actual, - .tracked_pins = .{}, + .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, - .viewport_pin = try pool.pins.create(), + .viewport_pin = viewport_pin, }; } @@ -357,32 +363,28 @@ pub fn clonePool( return result; } -/// Returns the viewport for the given offset, prefering to pin to -/// "active" if the offset is within the active area. -fn viewportForOffset(self: *const PageList, offset: RowOffset) Viewport { - // If the offset is on the active page, then we pin to active - // if our row idx is beyond the active row idx. - const active = self.getTopLeft(.active); - if (offset.page == active.page) { - if (offset.row_offset >= active.row_offset) { - return .{ .active = {} }; - } - } else { - var page_ = active.page.next; - while (page_) |page| { - // This loop is pretty fast because the active area is - // never that large so this is at most one, two pages for - // reasonable terminals (including very large real world - // ones). - - // A page forward in the active area is our page, so we're - // definitely in the active area. - if (page == offset.page) return .{ .active = {} }; - page_ = page.next; - } +/// Returns the viewport for the given pin, prefering to pin to +/// "active" if the pin is within the active area. +fn pinIsActive(self: *const PageList, p: Pin) bool { + // If the pin is in the active page, then we can quickly determine + // if we're beyond the end. + const active = self.getTopLeft2(.active); + if (p.page == active.page) return p.y >= active.y; + + var page_ = active.page.next; + while (page_) |page| { + // This loop is pretty fast because the active area is + // never that large so this is at most one, two pages for + // reasonable terminals (including very large real world + // ones). + + // A page forward in the active area is our page, so we're + // definitely in the active area. + if (page == p.page) return true; + page_ = page.next; } - return .{ .exact = offset }; + return false; } /// Resize options @@ -1245,11 +1247,11 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { .delta_row => |n| { if (n == 0) return; - const top = self.getTopLeft(.viewport); - const offset: RowOffset = if (n < 0) switch (top.backwardOverflow(@intCast(-n))) { + const top = self.getTopLeft2(.viewport); + const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { .offset => |v| v, .overflow => |v| v.end, - } else switch (top.forwardOverflow(@intCast(n))) { + } else switch (top.downOverflow(@intCast(n))) { .offset => |v| v, .overflow => |v| v.end, }; @@ -1261,7 +1263,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // But in a terminal when you get to the bottom and back into the // active area, you usually expect that the viewport will now // follow the active area. - self.viewport = self.viewportForOffset(offset); + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + return; + } + + // Pin is not active so we need to track it. + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; }, } } @@ -1438,16 +1447,6 @@ pub fn eraseRows( } } - // If our viewport is on this page and the offset is beyond - // our new end, shift it. - switch (self.viewport) { - .top, .active => {}, - .exact => |*offset| exact: { - if (offset.page != chunk.page) break :exact; - offset.row_offset -|= scroll_amount; - }, - } - // Our new size is the amount we scrolled chunk.page.data.size.rows = @intCast(scroll_amount); erased += chunk.end; @@ -1466,20 +1465,20 @@ pub fn eraseRows( }; } - // If we have an exact viewport, we need to adjust for active area. + // If we have a pinned viewport, we need to adjust for active area. switch (self.viewport) { .active => {}, - .exact => |offset| self.viewport = self.viewportForOffset(offset), + // For pin, we check if our pin is now in the active area and if so + // we move our viewport back to the active area. + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .{ .active = {} }; + }, // For top, we move back to active if our erasing moved our // top page into the active area. - .top => { - const vp = self.viewportForOffset(.{ - .page = self.pages.first.?, - .row_offset = 0, - }); - if (vp == .active) self.viewport = vp; + .top => if (self.pinIsActive(.{ .page = self.pages.first.? })) { + self.viewport = .{ .active = {} }; }, } } @@ -1500,20 +1499,6 @@ fn erasePage(self: *PageList, page: *List.Node) void { p.x = 0; } - // If our viewport is pinned to this page, then we need to update it. - switch (self.viewport) { - .top, .active => {}, - .exact => |*offset| { - if (offset.page == page) { - if (page.next) |next| { - offset.page = next; - } else { - self.viewport = .{ .active = {} }; - } - } - }, - } - // Remove the page from the linked list self.pages.remove(page); self.destroyPage(page); @@ -1548,6 +1533,7 @@ pub fn trackPin(self: *PageList, p: Pin) !*Pin { /// Untrack a previously tracked pin. This will deallocate the pin. pub fn untrackPin(self: *PageList, p: *Pin) void { + assert(p != self.viewport_pin); if (self.tracked_pins.remove(p)) { self.pool.pins.destroy(p); } @@ -1755,7 +1741,7 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { .viewport => switch (self.viewport) { .active => self.getTopLeft(.active), .top => self.getTopLeft(.screen), - .exact => |v| v, + .pin => .{ .page = self.viewport_pin.page, .row_offset = self.viewport_pin.y }, }, // The active area is calculated backwards from the last page. @@ -1787,7 +1773,7 @@ fn getTopLeft2(self: *const PageList, tag: point.Tag) Pin { .viewport => switch (self.viewport) { .active => self.getTopLeft2(.active), .top => self.getTopLeft2(.screen), - .exact => |v| .{ .page = v.page, .y = v.row_offset }, + .pin => self.viewport_pin.*, }, // The active area is calculated backwards from the last page. @@ -2647,7 +2633,8 @@ test "PageList erase resets viewport to active if moves within active" { // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport.exact.page == s.pages.first.?); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); @@ -2669,11 +2656,13 @@ test "PageList erase resets viewport if inside erased page but not active" { // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport.exact.page == s.pages.first.?); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 2 } }); - try testing.expect(s.viewport.exact.page == s.pages.first.?); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.page == s.pages.first.?); } test "PageList erase resets viewport to active if top is inside active" { From 92f0abee1bab39d500762597c22df5b527f60ac3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 11:53:45 -0800 Subject: [PATCH 176/428] terminal2: pointFromPin --- src/terminal2/PageList.zig | 183 ++++++++++++++++++++++++++++++++----- 1 file changed, 159 insertions(+), 24 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 7132ff2c01..144721b46b 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -363,30 +363,6 @@ pub fn clonePool( return result; } -/// Returns the viewport for the given pin, prefering to pin to -/// "active" if the pin is within the active area. -fn pinIsActive(self: *const PageList, p: Pin) bool { - // If the pin is in the active page, then we can quickly determine - // if we're beyond the end. - const active = self.getTopLeft2(.active); - if (p.page == active.page) return p.y >= active.y; - - var page_ = active.page.next; - while (page_) |page| { - // This loop is pretty fast because the active area is - // never that large so this is at most one, two pages for - // reasonable terminals (including very large real world - // ones). - - // A page forward in the active area is our page, so we're - // definitely in the active area. - if (page == p.page) return true; - page_ = page.next; - } - - return false; -} - /// Resize options pub const Resize = struct { /// The new cols/cells of the screen. @@ -1539,6 +1515,67 @@ pub fn untrackPin(self: *PageList, p: *Pin) void { } } +/// Returns the viewport for the given pin, prefering to pin to +/// "active" if the pin is within the active area. +fn pinIsActive(self: *const PageList, p: Pin) bool { + // If the pin is in the active page, then we can quickly determine + // if we're beyond the end. + const active = self.getTopLeft2(.active); + if (p.page == active.page) return p.y >= active.y; + + var page_ = active.page.next; + while (page_) |page| { + // This loop is pretty fast because the active area is + // never that large so this is at most one, two pages for + // reasonable terminals (including very large real world + // ones). + + // A page forward in the active area is our page, so we're + // definitely in the active area. + if (page == p.page) return true; + page_ = page.next; + } + + return false; +} + +/// Convert a pin to a point in the given context. If the pin can't fit +/// within the given tag (i.e. its in the history but you requested active), +/// then this will return null. +fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point { + const tl = self.getTopLeft2(tag); + + // Count our first page which is special because it may be partial. + var coord: point.Point.Coordinate = .{ .x = p.x }; + if (p.page == tl.page) { + // If our top-left is after our y then we're outside the range. + if (tl.y > p.y) return null; + coord.y = p.y - tl.y; + } else { + coord.y += tl.page.data.size.rows - tl.y - 1; + var page_ = tl.page.next; + while (page_) |page| : (page_ = page.next) { + if (page == p.page) { + coord.y += p.y; + break; + } + + coord.y += page.data.size.rows; + } else { + // We never saw our page, meaning we're outside the range. + return null; + } + } + + return switch (tag) { + inline else => |comptime_tag| @unionInit( + point.Point, + @tagName(comptime_tag), + coord, + ), + }; +} + /// Get the cell at the given point, or null if the cell does not /// exist or is out of bounds. /// @@ -2095,6 +2132,104 @@ test "PageList" { }, s.getTopLeft2(.active)); } +test "PageList pointFromPin active no history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 0, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }).?); + } + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 2, + .x = 4, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 2, + .x = 4, + }).?); + } +} + +test "PageList pointFromPin active with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 2, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 30, + .x = 2, + }).?); + } + + // In history, invalid + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 21, + .x = 2, + }) == null); + } +} + +test "PageList pointFromPin active from prior page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + + { + try testing.expectEqual(point.Point{ + .active = .{ + .y = 0, + .x = 2, + }, + }, s.pointFromPin(.active, .{ + .page = s.pages.last.?, + .y = 0, + .x = 2, + }).?); + } + + // Prior page + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }) == null); + } +} + test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; From a649bc237bdb47bab10817b55c7bbc5ebe97a3e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 12:07:41 -0800 Subject: [PATCH 177/428] terminal2: start testing pins with reflow --- src/terminal2/PageList.zig | 75 +++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 144721b46b..5629d6567a 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1542,7 +1542,13 @@ fn pinIsActive(self: *const PageList, p: Pin) bool { /// Convert a pin to a point in the given context. If the pin can't fit /// within the given tag (i.e. its in the history but you requested active), /// then this will return null. -fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point { +/// +/// Note that this can be a very expensive operation depending on the tag and +/// the location of the pin. This works by traversing the linked list of pages +/// in the tagged region. +/// +/// Therefore, this is recommended only very rarely. +pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point { const tl = self.getTopLeft2(tag); // Count our first page which is special because it may be partial. @@ -2924,18 +2930,21 @@ test "PageList resize (no reflow) more rows" { defer s.deinit(); try testing.expectEqual(@as(usize, 3), s.totalRows()); - // Cursor is at the bottom - var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .rows = 10, .reflow = false, .cursor = &cursor }); + try s.resize(.{ .rows = 10, .reflow = false }); try testing.expectEqual(@as(usize, 10), s.rows); try testing.expectEqual(@as(usize, 10), s.totalRows()); // Our cursor should not move because we have no scrollback so // we just grew. - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pointFromPin(.active, p.*).?); { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); @@ -2961,17 +2970,20 @@ test "PageList resize (no reflow) more rows with history" { } }, pt); } - // Cursor is at the bottom - var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .rows = 5, .reflow = false, .cursor = &cursor }); + try s.resize(.{ .rows = 5, .reflow = false }); try testing.expectEqual(@as(usize, 5), s.rows); try testing.expectEqual(@as(usize, 53), s.totalRows()); // Our cursor should move since it's in the scrollback - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 4), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pointFromPin(.active, p.*).?); { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); @@ -3037,9 +3049,11 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { }; } - // Let's say our cursor is in the scrollback - var cursor: Resize.Cursor = .{ .x = 0, .y = 2 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?); + defer s.untrackPin(p); { + const cursor = s.pointFromPin(.active, p.*).?.active; const get = s.getCell(.{ .active = .{ .x = cursor.x, .y = cursor.y, @@ -3048,13 +3062,16 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { } // Resize - try s.resize(.{ .rows = 5, .reflow = false, .cursor = &cursor }); + try s.resize(.{ .rows = 5, .reflow = false }); try testing.expectEqual(@as(usize, 5), s.rows); try testing.expectEqual(@as(usize, 10), s.totalRows()); // Our cursor should move since it's in the scrollback - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expect(s.pointFromPin(.active, p.*) == null); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pointFromPin(.screen, p.*).?); { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); @@ -3092,9 +3109,11 @@ test "PageList resize (no reflow) less rows trims blank lines" { }; } - // Let's say our cursor is at the top - var cursor: Resize.Cursor = .{ .x = 0, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?); + defer s.untrackPin(p); { + const cursor = s.pointFromPin(.active, p.*).?.active; const get = s.getCell(.{ .active = .{ .x = cursor.x, .y = cursor.y, @@ -3103,13 +3122,15 @@ test "PageList resize (no reflow) less rows trims blank lines" { } // Resize - try s.resize(.{ .rows = 2, .reflow = false, .cursor = &cursor }); + try s.resize(.{ .rows = 2, .reflow = false }); try testing.expectEqual(@as(usize, 2), s.rows); try testing.expectEqual(@as(usize, 2), s.totalRows()); // Our cursor should not move since we trimmed - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); { const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); @@ -3386,22 +3407,26 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" // Let's say our cursor is at the bottom var cursor: Resize.Cursor = .{ .x = 0, .y = s.rows - 2 }; + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = s.rows - 2 } }).?); + defer s.untrackPin(p); + const original_cursor = s.pointFromPin(.active, p.*).?.active; { const get = s.getCell(.{ .active = .{ - .x = cursor.x, - .y = cursor.y, + .x = original_cursor.x, + .y = original_cursor.y, } }).?; try testing.expectEqual(@as(u21, 3), get.cell.content.codepoint); } // Resize - const original_cursor = cursor; try s.resizeWithoutReflow(.{ .rows = 10, .reflow = false, .cursor = &cursor }); try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.rows); // Our cursor should not change - try testing.expectEqual(original_cursor, cursor); + try testing.expectEqual(original_cursor, s.pointFromPin(.active, p.*).?.active); // 12 because we have our 10 rows in the active + 2 in the scrollback // because we're preserving the cursor. From 9b9b8b1956e47b1f97ce0f39b96fa100a7c59a8e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 12:19:42 -0800 Subject: [PATCH 178/428] terminal2: lots more tracked pin logic --- src/terminal2/PageList.zig | 122 +++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 5629d6567a..7ff536798b 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -742,7 +742,7 @@ fn reflowPage( src_cursor.page_cell.wide == .wide and dst_cursor.x == cap.cols - 1) { - reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); dst_cursor.page_cell.* = .{ .content_tag = .codepoint, @@ -758,7 +758,7 @@ fn reflowPage( src_cursor.page_cell.wide == .spacer_head and dst_cursor.x != cap.cols - 1) { - reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); src_cursor.cursorForward(); continue; } @@ -846,7 +846,7 @@ fn reflowPage( // If our original cursor was on this page, this x/y then // we need to update to the new location. - reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); // Move both our cursors forward src_cursor.cursorForward(); @@ -859,6 +859,18 @@ fn reflowPage( // If we have no trailing empty cells, it can't be in the blanks. if (trailing_empty == 0) break :cursor; + // Update all our tracked pins + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x < cols_len) continue; + + p.page = dst_node; + p.y = dst_cursor.y; + } + // If we have no cursor, nothing to update. const c = cursor orelse break :cursor; const offset = c.offset orelse break :cursor; @@ -890,11 +902,25 @@ fn reflowPage( /// we're currently reflowing. This can then be fixed up later to an exact /// x/y (see resizeCols). fn reflowUpdateCursor( + self: *const PageList, cursor: ?*Resize.Cursor, src_cursor: *const ReflowCursor, dst_cursor: *const ReflowCursor, dst_node: *List.Node, ) void { + // Update all our tracked pins + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x != src_cursor.x) continue; + + p.page = dst_node; + p.x = dst_cursor.x; + p.y = dst_cursor.y; + } + const c = cursor orelse return; // If our original cursor was on this page, this x/y then @@ -3569,17 +3595,20 @@ test "PageList resize reflow more cols cursor in wrapped row" { } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 1, .y = 1 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 1 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 4, .reflow = true }); try testing.expectEqual(@as(usize, 4), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 3), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow more cols cursor in not wrapped row" { @@ -3617,17 +3646,20 @@ test "PageList resize reflow more cols cursor in not wrapped row" { } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 1, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 4, .reflow = true }); try testing.expectEqual(@as(usize, 4), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow more cols cursor in wrapped row that isn't unwrapped" { @@ -3679,17 +3711,20 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 1, .y = 2 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 2 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 4, .reflow = true }); try testing.expectEqual(@as(usize, 4), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow more cols no reflow preserves semantic prompt" { @@ -4170,17 +4205,20 @@ test "PageList resize reflow less cols cursor in wrapped row" { } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 2, .y = 1 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 1 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 2, .reflow = true }); try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 1), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow less cols cursor goes to scrollback" { @@ -4201,17 +4239,17 @@ test "PageList resize reflow less cols cursor goes to scrollback" { } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 2, .reflow = true }); try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expect(s.pointFromPin(.active, p.*) == null); } test "PageList resize reflow less cols cursor in unchanged row" { @@ -4232,17 +4270,20 @@ test "PageList resize reflow less cols cursor in unchanged row" { } } - // Set our cursor to be in the wrapped row - var cursor: Resize.Cursor = .{ .x = 1, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 2, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 2, .reflow = true }); try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 2), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 1), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow less cols cursor in blank cell" { @@ -4263,17 +4304,20 @@ test "PageList resize reflow less cols cursor in blank cell" { } } - // Set our cursor to be in a blank cell - var cursor: Resize.Cursor = .{ .x = 2, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 4, .reflow = true }); try testing.expectEqual(@as(usize, 4), s.cols); try testing.expectEqual(@as(usize, 2), s.totalRows()); - // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 2), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + // Our cursor should not move + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow less cols cursor in final blank cell" { From 6917bfa1598cd89cac7fcaa0806d51b005cb6af5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 13:42:45 -0800 Subject: [PATCH 179/428] terminal2: screen uses pins --- src/terminal2/PageList.zig | 313 ++++++++++++++++--------------------- src/terminal2/Screen.zig | 130 +++++++-------- src/terminal2/Terminal.zig | 42 ++--- 3 files changed, 226 insertions(+), 259 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 7ff536798b..adf792c9a0 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -373,20 +373,13 @@ pub const Resize = struct { /// be truncated if the new size is smaller than the old size. reflow: bool = true, - /// Set this to a cursor position and the resize will retain the - /// cursor position and update this so that the cursor remains over - /// the same original cell in the reflowed environment. - cursor: ?*Cursor = null, + /// Set this to the current cursor position in the active area. Some + /// resize/reflow behavior depends on the cursor position. + cursor: ?Cursor = null, pub const Cursor = struct { x: size.CellCountInt, y: size.CellCountInt, - - /// The row offset of the cursor. This is assumed to be correct - /// if set. If this is not set, then the row offset will be - /// calculated from the x/y. Calculating the row offset is expensive - /// so if you have it, you should set it. - offset: ?RowOffset = null, }; }; @@ -405,7 +398,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { .gt => { // We grow rows after cols so that we can do our unwrapping/reflow // before we do a no-reflow grow. - try self.resizeCols(cols, opts.cursor); + try self.resizeCols(cols); try self.resizeWithoutReflow(opts); }, @@ -418,7 +411,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { break :opts copy; }); - try self.resizeCols(cols, opts.cursor); + try self.resizeCols(cols); }, } } @@ -427,27 +420,12 @@ pub fn resize(self: *PageList, opts: Resize) !void { fn resizeCols( self: *PageList, cols: size.CellCountInt, - cursor: ?*Resize.Cursor, ) !void { assert(cols != self.cols); // Our new capacity, ensure we can fit the cols const cap = try std_capacity.adjust(.{ .cols = cols }); - // If we are given a cursor, we need to calculate the row offset. - if (cursor) |c| { - if (c.offset == null) { - const tl = self.getTopLeft(.active); - c.offset = tl.forward(c.y) orelse fail: { - // This should never happen, but its not critical enough to - // set an assertion and fail the program. The caller should ALWAYS - // input a valid x/y.. - log.err("cursor offset not found, resize will set wrong cursor", .{}); - break :fail null; - }; - } - } - // Go page by page and shrink the columns on a per-page basis. var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { @@ -462,7 +440,7 @@ fn resizeCols( if (row.wrap) break :wrapped true; } else false; if (!wrapped) { - try self.resizeWithoutReflowGrowCols(cap, chunk, cursor); + try self.resizeWithoutReflowGrowCols(cap, chunk); continue; } } @@ -470,7 +448,7 @@ fn resizeCols( // Note: we can do a fast-path here if all of our rows in this // page already fit within the new capacity. In that case we can // do a non-reflow resize. - try self.reflowPage(cap, chunk.page, cursor); + try self.reflowPage(cap, chunk.page); } // If our total rows is less than our active rows, we need to grow. @@ -485,50 +463,6 @@ fn resizeCols( for (total..self.rows) |_| _ = try self.grow(); } - // If we have a cursor, we need to update the correct y value. I'm - // not at all happy about this, I wish we could do this in a more - // efficient way as we resize the pages. But at the time of typing this - // I can't think of a way and I'd rather get things working. Someone please - // help! - // - // The challenge is that as rows are unwrapped, we want to preserve the - // cursor. So for examle if you have "A\nB" where AB is soft-wrapped and - // the cursor is on 'B' (x=0, y=1) and you grow the columns, we want - // the cursor to remain on B (x=1, y=0) as it grows. - // - // The easy thing to do would be to count how many rows we unwrapped - // and then subtract that from the original y. That's how I started. The - // challenge is that if we unwrap with scrollback, our scrollback is - // "pulled down" so that the original (x=0,y=0) line is now pushed down. - // Detecting this while resizing seems non-obvious. This is a tested case - // so if you change this logic, you should see failures or passes if it - // works. - // - // The approach I take instead is if we have a cursor offset, I work - // backwards to find the offset we marked while reflowing and update - // the y from that. This is _not terrible_ because active areas are - // generally small and this is a more or less linear search. Its just - // kind of clunky. - if (cursor) |c| cursor: { - const offset = c.offset orelse break :cursor; - var active_it = self.rowIterator(.{ .active = .{} }, null); - var y: size.CellCountInt = 0; - while (active_it.next()) |it_offset| { - if (it_offset.page == offset.page and - it_offset.row_offset == offset.row_offset) - { - c.y = y; - break :cursor; - } - - y += 1; - } else { - // Cursor moved off the screen into the scrollback. - c.x = 0; - c.y = 0; - } - } - // Update our cols self.cols = cols; } @@ -659,7 +593,6 @@ fn reflowPage( self: *PageList, cap: Capacity, node: *List.Node, - cursor: ?*Resize.Cursor, ) !void { // The cursor tracks where we are in the source page. var src_cursor = ReflowCursor.init(&node.data); @@ -742,7 +675,7 @@ fn reflowPage( src_cursor.page_cell.wide == .wide and dst_cursor.x == cap.cols - 1) { - self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); dst_cursor.page_cell.* = .{ .content_tag = .codepoint, @@ -758,7 +691,7 @@ fn reflowPage( src_cursor.page_cell.wide == .spacer_head and dst_cursor.x != cap.cols - 1) { - self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); src_cursor.cursorForward(); continue; } @@ -846,7 +779,7 @@ fn reflowPage( // If our original cursor was on this page, this x/y then // we need to update to the new location. - self.reflowUpdateCursor(cursor, &src_cursor, &dst_cursor, dst_node); + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); // Move both our cursors forward src_cursor.cursorForward(); @@ -870,22 +803,6 @@ fn reflowPage( p.page = dst_node; p.y = dst_cursor.y; } - - // If we have no cursor, nothing to update. - const c = cursor orelse break :cursor; - const offset = c.offset orelse break :cursor; - - // If our cursor is on this page, and our x is greater than - // our end, we update to the edge. - if (&offset.page.data == src_cursor.page and - offset.row_offset == src_cursor.y and - c.x >= cols_len) - { - c.offset = .{ - .page = dst_node, - .row_offset = dst_cursor.y, - }; - } } } else { // We made it through all our source rows, we're done. @@ -903,7 +820,6 @@ fn reflowPage( /// x/y (see resizeCols). fn reflowUpdateCursor( self: *const PageList, - cursor: ?*Resize.Cursor, src_cursor: *const ReflowCursor, dst_cursor: *const ReflowCursor, dst_node: *List.Node, @@ -920,42 +836,6 @@ fn reflowUpdateCursor( p.x = dst_cursor.x; p.y = dst_cursor.y; } - - const c = cursor orelse return; - - // If our original cursor was on this page, this x/y then - // we need to update to the new location. - const offset = c.offset orelse return; - if (&offset.page.data != src_cursor.page or - offset.row_offset != src_cursor.y or - c.x != src_cursor.x) return; - - // std.log.warn("c.x={} c.y={} dst_x={} dst_y={} src_y={}", .{ - // c.x, - // c.y, - // dst_cursor.x, - // dst_cursor.y, - // src_cursor.y, - // }); - - // Column always matches our dst x - c.x = dst_cursor.x; - - // Our y is more complicated. The cursor y is the active - // area y, not the row offset. Our cursors are row offsets. - // Instead of calculating the active area coord, we can - // better calculate the CHANGE in coordinate by subtracting - // our dst from src which will calculate how many rows - // we unwrapped to get here. - // - // Note this doesn't handle when we pull down scrollback. - // See the cursor updates in resizeGrowCols for that. - //c.y -|= src_cursor.y - dst_cursor.y; - - c.offset = .{ - .page = dst_node, - .row_offset = dst_cursor.y, - }; } fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { @@ -975,15 +855,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // 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 trimmed = self.trimTrailingBlankRows(self.rows - rows); - - // If we have a cursor, we want to preserve the y value as - // best we can. We need to subtract the number of rows that - // moved into the scrollback. - if (opts.cursor) |cursor| { - const scrollback = self.rows - rows - trimmed; - cursor.y -|= scrollback; - } + _ = self.trimTrailingBlankRows(self.rows - rows); // If we didn't trim enough, just modify our row count and this // will create additional history. @@ -1025,12 +897,6 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { for (count..rows) |_| _ = try self.grow(); } - // Update our cursor. W - if (opts.cursor) |cursor| { - const grow_len: size.CellCountInt = @intCast(rows -| count); - cursor.y += rows - self.rows - grow_len; - } - self.rows = rows; }, } @@ -1057,9 +923,12 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { page.size.cols = cols; } - if (opts.cursor) |cursor| { - // If our cursor is off the edge we trimmed, update to edge - if (cursor.x >= cols) cursor.x = cols - 1; + // Update all our tracked pins. If they have an X + // beyond the edge, clamp it. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.x >= cols) p.x = cols - 1; } self.cols = cols; @@ -1073,7 +942,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { var it = self.pageIterator(.{ .screen = .{} }, null); while (it.next()) |chunk| { - try self.resizeWithoutReflowGrowCols(cap, chunk, opts.cursor); + try self.resizeWithoutReflowGrowCols(cap, chunk); } self.cols = cols; @@ -1086,7 +955,6 @@ fn resizeWithoutReflowGrowCols( self: *PageList, cap: Capacity, chunk: PageIterator.Chunk, - cursor: ?*Resize.Cursor, ) !void { assert(cap.cols > self.cols); const page = &chunk.page.data; @@ -1138,19 +1006,15 @@ fn resizeWithoutReflowGrowCols( // Insert our new page self.pages.insertBefore(chunk.page, new_page); - // If we have a cursor, we need to update the row offset if it - // matches what we just copied. - if (cursor) |c| cursor: { - const offset = c.offset orelse break :cursor; - if (offset.page == chunk.page and - offset.row_offset >= y_start and - offset.row_offset < y_end) - { - c.offset = .{ - .page = new_page, - .row_offset = offset.row_offset - y_start, - }; - } + // Update our tracked pins that pointed to this previous page. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y < y_start or + p.y >= y_end) continue; + p.page = new_page; + p.y -= y_start; } } assert(copied == page.size.rows); @@ -1921,6 +1785,14 @@ pub const Pin = struct { y: usize = 0, x: usize = 0, + pub fn rowAndCell(self: Pin) struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + } { + const rac = self.page.data.getRowAndCell(self.x, self.y); + return .{ .row = rac.row, .cell = rac.cell }; + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { @@ -1953,6 +1825,7 @@ pub const Pin = struct { if (n <= rows) return .{ .offset = .{ .page = self.page, .y = n + self.y, + .x = self.x, } }; // Need to traverse page links to find the page @@ -1960,12 +1833,17 @@ pub const Pin = struct { var n_left: usize = n - rows; while (true) { page = page.next orelse return .{ .overflow = .{ - .end = .{ .page = page, .y = page.data.size.rows - 1 }, + .end = .{ + .page = page, + .y = page.data.size.rows - 1, + .x = self.x, + }, .remaining = n_left, } }; if (n_left <= page.data.size.rows) return .{ .offset = .{ .page = page, .y = n_left - 1, + .x = self.x, } }; n_left -= page.data.size.rows; } @@ -1984,6 +1862,7 @@ pub const Pin = struct { if (n <= self.y) return .{ .offset = .{ .page = self.page, .y = self.y - n, + .x = self.x, } }; // Need to traverse page links to find the page @@ -1991,12 +1870,13 @@ pub const Pin = struct { var n_left: usize = n - self.y; while (true) { page = page.prev orelse return .{ .overflow = .{ - .end = .{ .page = page, .y = 0 }, + .end = .{ .page = page, .y = 0, .x = self.x }, .remaining = n_left, } }; if (n_left <= page.data.size.rows) return .{ .offset = .{ .page = page, .y = page.data.size.rows - n_left, + .x = self.x, } }; n_left -= page.data.size.rows; } @@ -3054,6 +2934,58 @@ test "PageList resize (no reflow) less rows" { } } +test "PageList resize (no reflow) less rows cursor on bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 9 } }).?); + defer s.untrackPin(p); + { + const cursor = s.pointFromPin(.active, p.*).?.active; + const get = s.getCell(.{ .active = .{ + .x = cursor.x, + .y = cursor.y, + } }).?; + try testing.expectEqual(@as(u21, 9), get.cell.content.codepoint); + } + + // Resize + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move since it's in the scrollback + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pointFromPin(.active, p.*).?); + + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} test "PageList resize (no reflow) less rows cursor in scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -3227,6 +3159,35 @@ test "PageList resize (no reflow) less cols" { } } +test "PageList resize (no reflow) less cols pin in trimmed cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 8, .y = 2 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + var it = s.rowIterator(.{ .screen = .{} }, null); + while (it.next()) |offset| { + const rac = offset.rowAndCell(0); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(usize, 5), cells.len); + } + + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 2, + } }, s.pointFromPin(.active, p.*).?); +} + test "PageList resize (no reflow) less cols clears graphemes" { const testing = std.testing; const alloc = testing.allocator; @@ -3431,9 +3392,6 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" } }, pt); } - // Let's say our cursor is at the bottom - var cursor: Resize.Cursor = .{ .x = 0, .y = s.rows - 2 }; - // Put a tracked pin in the history const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = s.rows - 2 } }).?); defer s.untrackPin(p); @@ -3447,7 +3405,11 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" } // Resize - try s.resizeWithoutReflow(.{ .rows = 10, .reflow = false, .cursor = &cursor }); + try s.resizeWithoutReflow(.{ + .rows = 10, + .reflow = false, + .cursor = .{ .x = 0, .y = s.rows - 2 }, + }); try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.rows); @@ -4338,17 +4300,20 @@ test "PageList resize reflow less cols cursor in final blank cell" { } } - // Set our cursor to be in the final cell of our resized - var cursor: Resize.Cursor = .{ .x = 3, .y = 0 }; + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 3, .y = 0 } }).?); + defer s.untrackPin(p); // Resize - try s.resize(.{ .cols = 4, .reflow = true, .cursor = &cursor }); + try s.resize(.{ .cols = 4, .reflow = true }); try testing.expectEqual(@as(usize, 4), s.cols); try testing.expectEqual(@as(usize, 2), s.totalRows()); // Our cursor should move to the first row - try testing.expectEqual(@as(size.CellCountInt, 3), cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), cursor.y); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); } test "PageList resize reflow less cols blank lines" { diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 5326fb7e83..02918a836f 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -88,7 +88,7 @@ pub const Cursor = struct { /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. - page_offset: PageList.RowOffset, + page_pin: *PageList.Pin, page_row: *pagepkg.Row, page_cell: *pagepkg.Cell, }; @@ -143,14 +143,10 @@ pub fn init( var pages = try PageList.init(alloc, cols, rows, max_scrollback); errdefer pages.deinit(); - // The active area is guaranteed to be allocated and the first - // page in the list after init. This lets us quickly setup the cursor. - // This is MUCH faster than pages.rowOffset. - const page_offset: PageList.RowOffset = .{ - .page = pages.pages.first.?, - .row_offset = 0, - }; - const page_rac = page_offset.rowAndCell(0); + // 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, @@ -159,7 +155,7 @@ pub fn init( .cursor = .{ .x = 0, .y = 0, - .page_offset = page_offset, + .page_pin = page_pin, .page_row = page_rac.row, .page_cell = page_rac.cell, }, @@ -248,8 +244,9 @@ pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { assert(self.cursor.y > 0); - const page_offset = self.cursor.page_offset.backward(1).?; - const page_rac = page_offset.rowAndCell(self.pages.cols - 1); + 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; } @@ -260,6 +257,7 @@ pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { 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; } @@ -269,6 +267,7 @@ pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { 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; } @@ -278,9 +277,9 @@ pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.y >= n); - const page_offset = self.cursor.page_offset.backward(n).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; + 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; @@ -289,8 +288,8 @@ pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { assert(self.cursor.y >= n); - const page_offset = self.cursor.page_offset.backward(n).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); + const page_pin = self.cursor.page_pin.up(n).?; + const page_rac = page_pin.rowAndCell(); return page_rac.row; } @@ -302,9 +301,9 @@ pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { // 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_offset = self.cursor.page_offset.forward(n).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; + 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; @@ -316,7 +315,8 @@ pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { assert(x < self.pages.cols); - const page_rac = self.cursor.page_offset.rowAndCell(x); + 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; } @@ -326,14 +326,15 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) assert(x < self.pages.cols); assert(y < self.pages.rows); - const page_offset = if (y < self.cursor.y) - self.cursor.page_offset.backward(self.cursor.y - y).? + 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_offset.forward(y - self.cursor.y).? + self.cursor.page_pin.down(y - self.cursor.y).? else - self.cursor.page_offset; - const page_rac = page_offset.rowAndCell(x); - self.cursor.page_offset = page_offset; + 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; @@ -344,13 +345,24 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) /// 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 { - const get = self.pages.getCell(.{ .active = .{ - .x = self.cursor.x, - .y = self.cursor.y, - } }).?; - self.cursor.page_offset = .{ .page = get.page, .row_offset = get.row_idx }; - self.cursor.page_row = get.row; - self.cursor.page_cell = get.cell; + // 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. @@ -363,10 +375,11 @@ pub fn cursorDownScroll(self: *Screen) !void { // Erase rows will shift our rows up self.pages.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - // We need to reload our cursor because the pointers are now invalid. - const page_offset = self.cursor.page_offset; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; + // 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; @@ -374,17 +387,17 @@ pub fn cursorDownScroll(self: *Screen) !void { // we never write those rows again. Active erasing is a bit // different so we manually clear our one row. self.clearCells( - &page_offset.page.data, + &page_pin.page.data, self.cursor.page_row, - page_offset.page.data.getCells(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_offset = self.cursor.page_offset.forward(1).?; - const page_rac = page_offset.rowAndCell(self.cursor.x); - self.cursor.page_offset = page_offset; + 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; @@ -392,9 +405,9 @@ pub fn cursorDownScroll(self: *Screen) !void { // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { self.clearCells( - &page_offset.page.data, + &page_pin.page.data, self.cursor.page_row, - page_offset.page.data.getCells(self.cursor.page_row), + page_pin.page.data.getCells(self.cursor.page_row), ); } } @@ -623,19 +636,12 @@ fn resizeInternal( // No matter what we mark our image state as dirty self.kitty_images.dirty = true; - // Create a resize cursor. The resize operation uses this to keep our - // cursor over the same cell if possible. - var cursor: PageList.Resize.Cursor = .{ - .x = self.cursor.x, - .y = self.cursor.y, - }; - // Perform the resize operation. This will update cursor by reference. try self.pages.resize(.{ .rows = rows, .cols = cols, .reflow = reflow, - .cursor = &cursor, + .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, }); // If we have no scrollback and we shrunk our rows, we must explicitly @@ -647,11 +653,7 @@ fn resizeInternal( // If our cursor was updated, we do a full reload so all our cursor // state is correct. - if (cursor.x != self.cursor.x or cursor.y != self.cursor.y) { - self.cursor.x = cursor.x; - self.cursor.y = cursor.y; - self.cursorReload(); - } + self.cursorReload(); } /// Set a style attribute for the current cursor. @@ -798,7 +800,7 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { /// Call this whenever you manually change the cursor style. pub fn manualStyleUpdate(self: *Screen) !void { - var page = &self.cursor.page_offset.page.data; + var page = &self.cursor.page_pin.page.data; // Remove our previous style if is unused. if (self.cursor.style_ref) |ref| { @@ -1056,7 +1058,7 @@ test "Screen read and write scrollback" { } } -test "Screen read and write no scrollback" { +test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator; @@ -1103,7 +1105,7 @@ test "Screen style basics" { var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - const page = s.cursor.page_offset.page.data; + const page = s.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); // Set a new style @@ -1125,7 +1127,7 @@ test "Screen style reset to default" { var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - const page = s.cursor.page_offset.page.data; + const page = s.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); // Set a new style @@ -1145,7 +1147,7 @@ test "Screen style reset with unset" { var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); - const page = s.cursor.page_offset.page.data; + const page = s.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); // Set a new style @@ -1199,7 +1201,7 @@ test "Screen clearRows active styled line" { try s.setAttribute(.{ .unset = {} }); // We should have one style - const page = s.cursor.page_offset.page.data; + const page = s.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); s.clearRows(.{ .active = .{} }, null, false); diff --git a/src/terminal2/Terminal.zig b/src/terminal2/Terminal.zig index 8afa666f36..3347fed7e5 100644 --- a/src/terminal2/Terminal.zig +++ b/src/terminal2/Terminal.zig @@ -270,7 +270,7 @@ pub fn print(self: *Terminal, c: u21) !void { var state: unicode.GraphemeBreakState = .{}; var cp1: u21 = prev.cell.content.codepoint; if (prev.cell.hasGrapheme()) { - const cps = self.screen.cursor.page_offset.page.data.lookupGrapheme(prev.cell).?; + 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)); @@ -342,7 +342,7 @@ pub fn print(self: *Terminal, c: u21) !void { } log.debug("c={x} grapheme attach to left={}", .{ c, prev.left }); - try self.screen.cursor.page_offset.page.data.appendGrapheme( + try self.screen.cursor.page_pin.page.data.appendGrapheme( self.screen.cursor.page_row, prev.cell, c, @@ -399,7 +399,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (!emoji) return; } - try self.screen.cursor.page_offset.page.data.appendGrapheme( + try self.screen.cursor.page_pin.page.data.appendGrapheme( self.screen.cursor.page_row, prev, c, @@ -540,7 +540,7 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - self.screen.cursor.page_offset.page.data.clearGrapheme( + self.screen.cursor.page_pin.page.data.clearGrapheme( self.screen.cursor.page_row, cell, ); @@ -571,7 +571,7 @@ fn printCell( // 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_offset.page.data; + 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; @@ -1284,7 +1284,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { } // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_offset.page.data; + var page = &self.screen.cursor.page_pin.page.data; page.moveCells( src, self.scrolling_region.left, @@ -1300,7 +1300,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { const row: *Row = @ptrCast(top + i); // Clear the src row. - var page = &self.screen.cursor.page_offset.page.data; + 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); @@ -1378,7 +1378,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { } // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_offset.page.data; + var page = &self.screen.cursor.page_pin.page.data; page.moveCells( src, self.scrolling_region.left, @@ -1394,7 +1394,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const row: *Row = @ptrCast(y); // Clear the src row. - var page = &self.screen.cursor.page_offset.page.data; + 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); @@ -1442,7 +1442,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // 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_offset.page.data; + 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; @@ -1515,7 +1515,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { // 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_offset.page.data; + 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. @@ -1609,7 +1609,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // mode was not ISO we also always ignore protection attributes. if (self.screen.protected_mode != .iso) { self.screen.clearCells( - &self.screen.cursor.page_offset.page.data, + &self.screen.cursor.page_pin.page.data, self.screen.cursor.page_row, cells[0..end], ); @@ -1624,7 +1624,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; self.screen.clearCells( - &self.screen.cursor.page_offset.page.data, + &self.screen.cursor.page_pin.page.data, self.screen.cursor.page_row, cell_multi[0..1], ); @@ -1694,7 +1694,7 @@ pub fn eraseLine( // to fill the entire line. if (!protected) { self.screen.clearCells( - &self.screen.cursor.page_offset.page.data, + &self.screen.cursor.page_pin.page.data, self.screen.cursor.page_row, cells[start..end], ); @@ -1706,7 +1706,7 @@ pub fn eraseLine( const cell: *Cell = @ptrCast(&cell_multi[0]); if (cell.protected) continue; self.screen.clearCells( - &self.screen.cursor.page_offset.page.data, + &self.screen.cursor.page_pin.page.data, self.screen.cursor.page_row, cell_multi[0..1], ); @@ -3703,7 +3703,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); t.setCursorPos(2, 2); @@ -4378,7 +4378,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); t.setCursorPos(1, 1); @@ -5695,7 +5695,7 @@ test "Terminal: garbage collect overwritten" { } // verify we have no styles in our style map - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } @@ -5717,7 +5717,7 @@ test "Terminal: do not garbage collect old styles in use" { } // verify we have no styles in our style map - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } @@ -6024,7 +6024,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -6058,7 +6058,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = t.screen.cursor.page_offset.page.data; + const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); From 8745bff3a9e1c71578c7275ef51edfcd004cc42c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 13:49:27 -0800 Subject: [PATCH 180/428] terminal2: remove rowoffset --- src/terminal2/PageList.zig | 236 ++++++++----------------------------- src/terminal2/Screen.zig | 2 +- 2 files changed, 53 insertions(+), 185 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index adf792c9a0..4b78b15148 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1113,7 +1113,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { .delta_row => |n| { if (n == 0) return; - const top = self.getTopLeft2(.viewport); + const top = self.getTopLeft(.viewport); const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { .offset => |v| v, .overflow => |v| v.end, @@ -1373,7 +1373,7 @@ fn erasePage(self: *PageList, page: *List.Node) void { /// Returns the pin for the given point. The pin is NOT tracked so it /// is only valid as long as the pagelist isn't modified. pub fn pin(self: *const PageList, pt: point.Point) ?Pin { - var p = self.getTopLeft2(pt).down(pt.coord().y) orelse return null; + var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; p.x = pt.coord().x; return p; } @@ -1410,7 +1410,7 @@ pub fn untrackPin(self: *PageList, p: *Pin) void { fn pinIsActive(self: *const PageList, p: Pin) bool { // If the pin is in the active page, then we can quickly determine // if we're beyond the end. - const active = self.getTopLeft2(.active); + const active = self.getTopLeft(.active); if (p.page == active.page) return p.y >= active.y; var page_ = active.page.next; @@ -1439,7 +1439,7 @@ fn pinIsActive(self: *const PageList, p: Pin) bool { /// /// Therefore, this is recommended only very rarely. pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point { - const tl = self.getTopLeft2(tag); + const tl = self.getTopLeft(tag); // Count our first page which is special because it may be partial. var coord: point.Point.Coordinate = .{ .x = p.x }; @@ -1493,9 +1493,9 @@ pub const RowIterator = struct { chunk: ?PageIterator.Chunk = null, offset: usize = 0, - pub fn next(self: *RowIterator) ?RowOffset { + pub fn next(self: *RowIterator) ?Pin { const chunk = self.chunk orelse return null; - const row: RowOffset = .{ .page = chunk.page, .row_offset = self.offset }; + const row: Pin = .{ .page = chunk.page, .y = self.offset }; // Increase our offset in the chunk self.offset += 1; @@ -1526,13 +1526,13 @@ pub fn rowIterator( } pub const PageIterator = struct { - row: ?RowOffset = null, + row: ?Pin = null, limit: Limit = .none, const Limit = union(enum) { none, count: usize, - row: RowOffset, + row: Pin, }; pub fn next(self: *PageIterator) ?Chunk { @@ -1550,16 +1550,16 @@ pub const PageIterator = struct { break :none .{ .page = row.page, - .start = row.row_offset, + .start = row.y, .end = row.page.data.size.rows, }; }, .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.page.data.size.rows - row.row_offset, limit.*); + const len = @min(row.page.data.size.rows - row.y, limit.*); if (len > limit.*) { - self.row = row.forward(len); + self.row = row.down(len); limit.* -= len; } else { self.row = null; @@ -1567,8 +1567,8 @@ pub const PageIterator = struct { break :count .{ .page = row.page, - .start = row.row_offset, - .end = row.row_offset + len, + .start = row.y, + .end = row.y + len, }; }, @@ -1583,7 +1583,7 @@ pub const PageIterator = struct { break :row .{ .page = row.page, - .start = row.row_offset, + .start = row.y, .end = row.page.data.size.rows, }; } @@ -1591,11 +1591,11 @@ pub const PageIterator = struct { // If this is the same page then we only consume up to // the limit row. self.row = null; - if (row.row_offset > limit_row.row_offset) return null; + if (row.y > limit_row.y) return null; break :row .{ .page = row.page, - .start = row.row_offset, - .end = limit_row.row_offset + 1, + .start = row.y, + .end = limit_row.y + 1, }; }, }; @@ -1641,7 +1641,7 @@ pub fn pageIterator( const limit: PageIterator.Limit = limit: { if (bl_pt) |pt| { const bl = self.getTopLeft(pt); - break :limit .{ .row = bl.forward(pt.coord().y).? }; + break :limit .{ .row = bl.down(pt.coord().y).? }; } break :limit switch (tl_pt) { @@ -1655,18 +1655,18 @@ pub fn pageIterator( // to calculate but also more rare of a thing to iterate over. .history => history: { const active_tl = self.getTopLeft(.active); - const history_bot = active_tl.backward(1) orelse + const history_bot = active_tl.up(1) orelse return .{ .row = null }; break :history .{ .row = history_bot }; }, }; }; - return .{ .row = tl.forward(tl_pt.coord().y), .limit = limit }; + return .{ .row = tl.down(tl_pt.coord().y), .limit = limit }; } /// Get the top-left of the screen for the given tag. -fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { +fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { return switch (tag) { // The full screen or history is always just the first page. .screen, .history => .{ .page = self.pages.first.? }, @@ -1674,38 +1674,6 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) RowOffset { .viewport => switch (self.viewport) { .active => self.getTopLeft(.active), .top => self.getTopLeft(.screen), - .pin => .{ .page = self.viewport_pin.page, .row_offset = self.viewport_pin.y }, - }, - - // The active area is calculated backwards from the last page. - // This makes getting the active top left slower but makes scrolling - // much faster because we don't need to update the top left. Under - // heavy load this makes a measurable difference. - .active => active: { - var page = self.pages.last.?; - var rem = self.rows; - while (rem > page.data.size.rows) { - rem -= page.data.size.rows; - page = page.prev.?; // assertion: we always have enough rows for active - } - - break :active .{ - .page = page, - .row_offset = page.data.size.rows - rem, - }; - }, - }; -} - -/// Get the top-left of the screen for the given tag. -fn getTopLeft2(self: *const PageList, tag: point.Tag) Pin { - return switch (tag) { - // The full screen or history is always just the first page. - .screen, .history => .{ .page = self.pages.first.? }, - - .viewport => switch (self.viewport) { - .active => self.getTopLeft2(.active), - .top => self.getTopLeft2(.screen), .pin => self.viewport_pin.*, }, @@ -1883,110 +1851,6 @@ pub const Pin = struct { } }; -/// Represents some y coordinate within the screen. Since pages can -/// be split at any row boundary, getting some Y-coordinate within -/// any part of the screen may map to a different page and row offset -/// than the original y-coordinate. This struct represents that mapping. -pub const RowOffset = struct { - page: *List.Node, - row_offset: usize = 0, - - pub fn eql(self: RowOffset, other: RowOffset) bool { - return self.page == other.page and self.row_offset == other.row_offset; - } - - pub fn rowAndCell(self: RowOffset, x: usize) struct { - row: *pagepkg.Row, - cell: *pagepkg.Cell, - } { - const rac = self.page.data.getRowAndCell(x, self.row_offset); - return .{ .row = rac.row, .cell = rac.cell }; - } - - /// Get the row at the given row index from this Topleft. This - /// may require traversing into the next page if the row index - /// is greater than the number of rows in this page. - /// - /// This will return null if the row index is out of bounds. - pub fn forward(self: RowOffset, idx: usize) ?RowOffset { - return switch (self.forwardOverflow(idx)) { - .offset => |v| v, - .overflow => null, - }; - } - - /// TODO: docs - pub fn backward(self: RowOffset, idx: usize) ?RowOffset { - return switch (self.backwardOverflow(idx)) { - .offset => |v| v, - .overflow => null, - }; - } - - /// Move the offset forward n rows. If the offset goes beyond the - /// end of the screen, return the overflow amount. - fn forwardOverflow(self: RowOffset, n: usize) union(enum) { - offset: RowOffset, - overflow: struct { - end: RowOffset, - remaining: usize, - }, - } { - // Index fits within this page - const rows = self.page.data.size.rows - (self.row_offset + 1); - if (n <= rows) return .{ .offset = .{ - .page = self.page, - .row_offset = n + self.row_offset, - } }; - - // Need to traverse page links to find the page - var page: *List.Node = self.page; - var n_left: usize = n - rows; - while (true) { - page = page.next orelse return .{ .overflow = .{ - .end = .{ .page = page, .row_offset = page.data.size.rows - 1 }, - .remaining = n_left, - } }; - if (n_left <= page.data.size.rows) return .{ .offset = .{ - .page = page, - .row_offset = n_left - 1, - } }; - n_left -= page.data.size.rows; - } - } - - /// Move the offset backward n rows. If the offset goes beyond the - /// start of the screen, return the overflow amount. - fn backwardOverflow(self: RowOffset, n: usize) union(enum) { - offset: RowOffset, - overflow: struct { - end: RowOffset, - remaining: usize, - }, - } { - // Index fits within this page - if (n <= self.row_offset) return .{ .offset = .{ - .page = self.page, - .row_offset = self.row_offset - n, - } }; - - // Need to traverse page links to find the page - var page: *List.Node = self.page; - var n_left: usize = n - self.row_offset; - while (true) { - page = page.prev orelse return .{ .overflow = .{ - .end = .{ .page = page, .row_offset = 0 }, - .remaining = n_left, - } }; - if (n_left <= page.data.size.rows) return .{ .offset = .{ - .page = page, - .row_offset = page.data.size.rows - n_left, - } }; - n_left -= page.data.size.rows; - } - } -}; - const Cell = struct { page: *List.Node, row: *pagepkg.Row, @@ -2041,7 +1905,7 @@ test "PageList" { .page = s.pages.first.?, .y = 0, .x = 0, - }, s.getTopLeft2(.active)); + }, s.getTopLeft(.active)); } test "PageList pointFromPin active no history" { @@ -2567,7 +2431,7 @@ test "PageList pageIterator history two pages" { try testing.expect(chunk.page == s.pages.first.?); const start: usize = 0; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(active_tl.row_offset, chunk.end); + try testing.expectEqual(active_tl.y, chunk.end); } try testing.expect(it.next() == null); } @@ -3153,7 +3017,7 @@ test "PageList resize (no reflow) less cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -3177,7 +3041,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -3232,7 +3096,7 @@ test "PageList resize (no reflow) more cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } @@ -3256,7 +3120,7 @@ test "PageList resize (no reflow) less cols then more cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -3276,7 +3140,7 @@ test "PageList resize (no reflow) less rows and cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -3297,7 +3161,7 @@ test "PageList resize (no reflow) more rows and less cols" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -3318,7 +3182,7 @@ test "PageList resize (no reflow) empty screen" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } @@ -3357,7 +3221,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { try testing.expectEqual(rows, s.totalRows()); var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, cap2.cols), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -3462,7 +3326,7 @@ test "PageList resize reflow more cols no wrapped rows" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -3513,7 +3377,7 @@ test "PageList resize reflow more cols wrapped rows" { { // First row should be unwrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 4), cells.len); @@ -3989,7 +3853,9 @@ test "PageList resize reflow less cols no wrapped rows" { var it = s.rowIterator(.{ .screen = .{} }, null); while (it.next()) |offset| { for (0..4) |x| { - const rac = offset.rowAndCell(x); + var offset_copy = offset; + offset_copy.x = x; + const rac = offset_copy.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); @@ -4033,7 +3899,7 @@ test "PageList resize reflow less cols wrapped rows" { { // First row should be wrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4041,7 +3907,7 @@ test "PageList resize reflow less cols wrapped rows" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4050,7 +3916,7 @@ test "PageList resize reflow less cols wrapped rows" { { // First row should be wrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4058,7 +3924,7 @@ test "PageList resize reflow less cols wrapped rows" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4109,7 +3975,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { // First row should be wrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4117,7 +3983,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4130,7 +3996,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { // First row should be wrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4138,7 +4004,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4343,7 +4209,7 @@ test "PageList resize reflow less cols blank lines" { { // First row should be wrapped const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4351,7 +4217,7 @@ test "PageList resize reflow less cols blank lines" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4394,12 +4260,12 @@ test "PageList resize reflow less cols blank lines between" { var it = s.rowIterator(.{ .active = .{} }, null); { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); try testing.expect(!rac.row.wrap); } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4407,7 +4273,7 @@ test "PageList resize reflow less cols blank lines between" { } { const offset = it.next().?; - const rac = offset.rowAndCell(0); + const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); @@ -4449,7 +4315,9 @@ test "PageList resize reflow less cols copy style" { var it = s.rowIterator(.{ .active = .{} }, null); while (it.next()) |offset| { for (0..s.cols - 1) |x| { - const rac = offset.rowAndCell(x); + var offset_copy = offset; + offset_copy.x = x; + const rac = offset_copy.rowAndCell(); const style_id = rac.cell.style_id; try testing.expect(style_id != 0); diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 02918a836f..2f8b545566 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -854,7 +854,7 @@ pub fn dumpString( var iter = self.pages.rowIterator(tl, null); while (iter.next()) |row_offset| { - const rac = row_offset.rowAndCell(0); + const rac = row_offset.rowAndCell(); const cells = cells: { const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); break :cells cells[0..self.pages.cols]; From 373462ba433bb5b9f15f7bf119def6c02418d186 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 14:15:01 -0800 Subject: [PATCH 181/428] terminal2: starting to port kitty graphics --- src/terminal2/PageList.zig | 4 + src/terminal2/kitty.zig | 3 + src/terminal2/kitty/graphics.zig | 22 + src/terminal2/kitty/graphics_command.zig | 984 ++++++++++++++++++ src/terminal2/kitty/graphics_exec.zig | 344 ++++++ src/terminal2/kitty/graphics_image.zig | 776 ++++++++++++++ src/terminal2/kitty/graphics_storage.zig | 919 ++++++++++++++++ src/terminal2/kitty/key.zig | 151 +++ .../image-png-none-50x76-2147483647-raw.data | Bin 0 -> 86 bytes .../image-rgb-none-20x15-2147483647.data | 1 + ...ge-rgb-zlib_deflate-128x96-2147483647.data | 1 + src/terminal2/main.zig | 1 + 12 files changed, 3206 insertions(+) create mode 100644 src/terminal2/kitty/graphics.zig create mode 100644 src/terminal2/kitty/graphics_command.zig create mode 100644 src/terminal2/kitty/graphics_exec.zig create mode 100644 src/terminal2/kitty/graphics_image.zig create mode 100644 src/terminal2/kitty/graphics_storage.zig create mode 100644 src/terminal2/kitty/key.zig create mode 100644 src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data create mode 100644 src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data create mode 100644 src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 4b78b15148..02bcd82dab 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1405,6 +1405,10 @@ pub fn untrackPin(self: *PageList, p: *Pin) void { } } +pub fn countTrackedPins(self: *const PageList) usize { + return self.tracked_pins.count(); +} + /// Returns the viewport for the given pin, prefering to pin to /// "active" if the pin is within the active area. fn pinIsActive(self: *const PageList, p: Pin) bool { diff --git a/src/terminal2/kitty.zig b/src/terminal2/kitty.zig index 6b86a3280c..e2341a3dc6 100644 --- a/src/terminal2/kitty.zig +++ b/src/terminal2/kitty.zig @@ -6,4 +6,7 @@ pub usingnamespace @import("../terminal/kitty/key.zig"); test { @import("std").testing.refAllDecls(@This()); + + _ = @import("kitty/graphics.zig"); + _ = @import("kitty/key.zig"); } diff --git a/src/terminal2/kitty/graphics.zig b/src/terminal2/kitty/graphics.zig new file mode 100644 index 0000000000..cfc45adbc4 --- /dev/null +++ b/src/terminal2/kitty/graphics.zig @@ -0,0 +1,22 @@ +//! Kitty graphics protocol support. +//! +//! Documentation: +//! https://sw.kovidgoyal.net/kitty/graphics-protocol +//! +//! Unimplemented features that are still todo: +//! - shared memory transmit +//! - virtual placement w/ unicode +//! - animation +//! +//! Performance: +//! The performance of this particular subsystem of Ghostty is not great. +//! We can avoid a lot more allocations, we can replace some C code (which +//! implicitly allocates) with native Zig, we can improve the data structures +//! to avoid repeated lookups, etc. I tried to avoid pessimization but my +//! aim to ship a v1 of this implementation came at some cost. I learned a lot +//! though and I think we can go back through and fix this up. + +pub usingnamespace @import("graphics_command.zig"); +pub usingnamespace @import("graphics_exec.zig"); +pub usingnamespace @import("graphics_image.zig"); +pub usingnamespace @import("graphics_storage.zig"); diff --git a/src/terminal2/kitty/graphics_command.zig b/src/terminal2/kitty/graphics_command.zig new file mode 100644 index 0000000000..ca7a4d674b --- /dev/null +++ b/src/terminal2/kitty/graphics_command.zig @@ -0,0 +1,984 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// The key-value pairs for the control information for a command. The +/// keys are always single characters and the values are either single +/// characters or 32-bit unsigned integers. +/// +/// For the value of this: if the value is a single printable ASCII character +/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. +const KV = std.AutoHashMapUnmanaged(u8, u32); + +/// Command parser parses the Kitty graphics protocol escape sequence. +pub const CommandParser = struct { + /// The memory used by the parser is stored in an arena because it is + /// all freed at the end of the command. + arena: ArenaAllocator, + + /// This is the list of KV pairs that we're building up. + kv: KV = .{}, + + /// This is used as a buffer to store the key/value of a KV pair. + /// The value of a KV pair is at most a 32-bit integer which at most + /// is 10 characters (4294967295). + kv_temp: [10]u8 = undefined, + kv_temp_len: u4 = 0, + kv_current: u8 = 0, // Current kv key + + /// This is the list of bytes that contains both KV data and final + /// data. You shouldn't access this directly. + data: std.ArrayList(u8), + + /// Internal state for parsing. + state: State = .control_key, + + const State = enum { + /// Parsing k/v pairs. The "ignore" variants are in that state + /// but ignore any data because we know they're invalid. + control_key, + control_key_ignore, + control_value, + control_value_ignore, + + /// We're parsing the data blob. + data, + }; + + /// Initialize the parser. The allocator given will be used for both + /// temporary data and long-lived values such as the final image blob. + pub fn init(alloc: Allocator) CommandParser { + var arena = ArenaAllocator.init(alloc); + errdefer arena.deinit(); + return .{ + .arena = arena, + .data = std.ArrayList(u8).init(alloc), + }; + } + + pub fn deinit(self: *CommandParser) void { + // We don't free the hash map because its in the arena + self.arena.deinit(); + self.data.deinit(); + } + + /// Feed a single byte to the parser. + /// + /// The first byte to start parsing should be the byte immediately following + /// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should + /// be "1". + pub fn feed(self: *CommandParser, c: u8) !void { + switch (self.state) { + .control_key => switch (c) { + // '=' means the key is complete and we're moving to the value. + '=' => if (self.kv_temp_len != 1) { + // All control keys are a single character right now so + // if we're not a single character just ignore follow-up + // data. + self.state = .control_value_ignore; + self.kv_temp_len = 0; + } else { + self.kv_current = self.kv_temp[0]; + self.kv_temp_len = 0; + self.state = .control_value; + }, + + else => try self.accumulateValue(c, .control_key_ignore), + }, + + .control_key_ignore => switch (c) { + '=' => self.state = .control_value_ignore, + else => {}, + }, + + .control_value => switch (c) { + ',' => try self.finishValue(.control_key), // move to next key + ';' => try self.finishValue(.data), // move to data + else => try self.accumulateValue(c, .control_value_ignore), + }, + + .control_value_ignore => switch (c) { + ',' => self.state = .control_key_ignore, + ';' => self.state = .data, + else => {}, + }, + + .data => try self.data.append(c), + } + + // We always add to our data list because this is our stable + // array of bytes that we'll reference everywhere else. + } + + /// Complete the parsing. This must be called after all the + /// bytes have been fed to the parser. + /// + /// The allocator given will be used for the long-lived data + /// of the final command. + pub fn complete(self: *CommandParser) !Command { + switch (self.state) { + // We can't ever end in the control key state and be valid. + // This means the command looked something like "a=1,b" + .control_key, .control_key_ignore => return error.InvalidFormat, + + // Some commands (i.e. placements) end without extra data so + // we end in the value state. i.e. "a=1,b=2" + .control_value => try self.finishValue(.data), + .control_value_ignore => {}, + + // Most commands end in data, i.e. "a=1,b=2;1234" + .data => {}, + } + + // Determine our action, which is always a single character. + const action: u8 = action: { + const value = self.kv.get('a') orelse break :action 't'; + const c = std.math.cast(u8, value) orelse return error.InvalidFormat; + break :action c; + }; + const control: Command.Control = switch (action) { + 'q' => .{ .query = try Transmission.parse(self.kv) }, + 't' => .{ .transmit = try Transmission.parse(self.kv) }, + 'T' => .{ .transmit_and_display = .{ + .transmission = try Transmission.parse(self.kv), + .display = try Display.parse(self.kv), + } }, + 'p' => .{ .display = try Display.parse(self.kv) }, + 'd' => .{ .delete = try Delete.parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, + 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, + 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, + else => return error.InvalidFormat, + }; + + // Determine our quiet value + const quiet: Command.Quiet = if (self.kv.get('q')) |v| quiet: { + break :quiet switch (v) { + 0 => .no, + 1 => .ok, + 2 => .failures, + else => return error.InvalidFormat, + }; + } else .no; + + return .{ + .control = control, + .quiet = quiet, + .data = if (self.data.items.len == 0) "" else data: { + break :data try self.data.toOwnedSlice(); + }, + }; + } + + fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void { + const idx = self.kv_temp_len; + self.kv_temp_len += 1; + if (self.kv_temp_len > self.kv_temp.len) { + self.state = overflow_state; + self.kv_temp_len = 0; + return; + } + self.kv_temp[idx] = c; + } + + fn finishValue(self: *CommandParser, next_state: State) !void { + const alloc = self.arena.allocator(); + + // We can move states right away, we don't use it. + self.state = next_state; + + // Check for ASCII chars first + if (self.kv_temp_len == 1) { + const c = self.kv_temp[0]; + if (c < '0' or c > '9') { + try self.kv.put(alloc, self.kv_current, @intCast(c)); + self.kv_temp_len = 0; + return; + } + } + + // Only "z" is currently signed. This is a bit of a kloodge; if more + // fields become signed we can rethink this but for now we parse + // "z" as i32 then bitcast it to u32 then bitcast it back later. + if (self.kv_current == 'z') { + const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10); + try self.kv.put(alloc, self.kv_current, @bitCast(v)); + } else { + const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10); + try self.kv.put(alloc, self.kv_current, v); + } + + // Clear our temp buffer + self.kv_temp_len = 0; + } +}; + +/// Represents a possible response to a command. +pub const Response = struct { + id: u32 = 0, + image_number: u32 = 0, + placement_id: u32 = 0, + message: []const u8 = "OK", + + pub fn encode(self: Response, writer: anytype) !void { + // We only encode a result if we have either an id or an image number. + if (self.id == 0 and self.image_number == 0) return; + + try writer.writeAll("\x1b_G"); + if (self.id > 0) { + try writer.print("i={}", .{self.id}); + } + if (self.image_number > 0) { + if (self.id > 0) try writer.writeByte(','); + try writer.print("I={}", .{self.image_number}); + } + if (self.placement_id > 0) { + try writer.print(",p={}", .{self.placement_id}); + } + try writer.writeByte(';'); + try writer.writeAll(self.message); + try writer.writeAll("\x1b\\"); + } + + /// Returns true if this response is not an error. + pub fn ok(self: Response) bool { + return std.mem.eql(u8, self.message, "OK"); + } +}; + +pub const Command = struct { + control: Control, + quiet: Quiet = .no, + data: []const u8 = "", + + pub const Action = enum { + query, // q + transmit, // t + transmit_and_display, // T + display, // p + delete, // d + transmit_animation_frame, // f + control_animation, // a + compose_animation, // c + }; + + pub const Quiet = enum { + no, // 0 + ok, // 1 + failures, // 2 + }; + + pub const Control = union(Action) { + query: Transmission, + transmit: Transmission, + transmit_and_display: struct { + transmission: Transmission, + display: Display, + }, + display: Display, + delete: Delete, + transmit_animation_frame: AnimationFrameLoading, + control_animation: AnimationControl, + compose_animation: AnimationFrameComposition, + }; + + /// Take ownership over the data in this command. If the returned value + /// has a length of zero, then the data was empty and need not be freed. + pub fn toOwnedData(self: *Command) []const u8 { + const result = self.data; + self.data = ""; + return result; + } + + /// Returns the transmission data if it has any. + pub fn transmission(self: Command) ?Transmission { + return switch (self.control) { + .query => |t| t, + .transmit => |t| t, + .transmit_and_display => |t| t.transmission, + else => null, + }; + } + + /// Returns the display data if it has any. + pub fn display(self: Command) ?Display { + return switch (self.control) { + .display => |d| d, + .transmit_and_display => |t| t.display, + else => null, + }; + } + + pub fn deinit(self: Command, alloc: Allocator) void { + if (self.data.len > 0) alloc.free(self.data); + } +}; + +pub const Transmission = struct { + format: Format = .rgb, // f + medium: Medium = .direct, // t + width: u32 = 0, // s + height: u32 = 0, // v + size: u32 = 0, // S + offset: u32 = 0, // O + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + compression: Compression = .none, // o + more_chunks: bool = false, // m + + pub const Format = enum { + rgb, // 24 + rgba, // 32 + png, // 100 + + // The following are not supported directly via the protocol + // but they are formats that a png may decode to that we + // support. + grey_alpha, + }; + + pub const Medium = enum { + direct, // d + file, // f + temporary_file, // t + shared_memory, // s + }; + + pub const Compression = enum { + none, + zlib_deflate, // z + }; + + fn parse(kv: KV) !Transmission { + var result: Transmission = .{}; + if (kv.get('f')) |v| { + result.format = switch (v) { + 24 => .rgb, + 32 => .rgba, + 100 => .png, + else => return error.InvalidFormat, + }; + } + + if (kv.get('t')) |v| { + const c = std.math.cast(u8, v) orelse return error.InvalidFormat; + result.medium = switch (c) { + 'd' => .direct, + 'f' => .file, + 't' => .temporary_file, + 's' => .shared_memory, + else => return error.InvalidFormat, + }; + } + + if (kv.get('s')) |v| { + result.width = v; + } + + if (kv.get('v')) |v| { + result.height = v; + } + + if (kv.get('S')) |v| { + result.size = v; + } + + if (kv.get('O')) |v| { + result.offset = v; + } + + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + + if (kv.get('o')) |v| { + const c = std.math.cast(u8, v) orelse return error.InvalidFormat; + result.compression = switch (c) { + 'z' => .zlib_deflate, + else => return error.InvalidFormat, + }; + } + + if (kv.get('m')) |v| { + result.more_chunks = v > 0; + } + + return result; + } +}; + +pub const Display = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + x: u32 = 0, // x + y: u32 = 0, // y + width: u32 = 0, // w + height: u32 = 0, // h + x_offset: u32 = 0, // X + y_offset: u32 = 0, // Y + columns: u32 = 0, // c + rows: u32 = 0, // r + cursor_movement: CursorMovement = .after, // C + virtual_placement: bool = false, // U + z: i32 = 0, // z + + pub const CursorMovement = enum { + after, // 0 + none, // 1 + }; + + fn parse(kv: KV) !Display { + var result: Display = .{}; + + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + + if (kv.get('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('w')) |v| { + result.width = v; + } + + if (kv.get('h')) |v| { + result.height = v; + } + + if (kv.get('X')) |v| { + result.x_offset = v; + } + + if (kv.get('Y')) |v| { + result.y_offset = v; + } + + if (kv.get('c')) |v| { + result.columns = v; + } + + if (kv.get('r')) |v| { + result.rows = v; + } + + if (kv.get('C')) |v| { + result.cursor_movement = switch (v) { + 0 => .after, + 1 => .none, + else => return error.InvalidFormat, + }; + } + + if (kv.get('U')) |v| { + result.virtual_placement = switch (v) { + 0 => false, + 1 => true, + else => return error.InvalidFormat, + }; + } + + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.z = @bitCast(v); + } + + return result; + } +}; + +pub const AnimationFrameLoading = struct { + x: u32 = 0, // x + y: u32 = 0, // y + create_frame: u32 = 0, // c + edit_frame: u32 = 0, // r + gap_ms: u32 = 0, // z + composition_mode: CompositionMode = .alpha_blend, // X + background: Background = .{}, // Y + + pub const Background = packed struct(u32) { + r: u8 = 0, + g: u8 = 0, + b: u8 = 0, + a: u8 = 0, + }; + + fn parse(kv: KV) !AnimationFrameLoading { + var result: AnimationFrameLoading = .{}; + + if (kv.get('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('c')) |v| { + result.create_frame = v; + } + + if (kv.get('r')) |v| { + result.edit_frame = v; + } + + if (kv.get('z')) |v| { + result.gap_ms = v; + } + + if (kv.get('X')) |v| { + result.composition_mode = switch (v) { + 0 => .alpha_blend, + 1 => .overwrite, + else => return error.InvalidFormat, + }; + } + + if (kv.get('Y')) |v| { + result.background = @bitCast(v); + } + + return result; + } +}; + +pub const AnimationFrameComposition = struct { + frame: u32 = 0, // c + edit_frame: u32 = 0, // r + x: u32 = 0, // x + y: u32 = 0, // y + width: u32 = 0, // w + height: u32 = 0, // h + left_edge: u32 = 0, // X + top_edge: u32 = 0, // Y + composition_mode: CompositionMode = .alpha_blend, // C + + fn parse(kv: KV) !AnimationFrameComposition { + var result: AnimationFrameComposition = .{}; + + if (kv.get('c')) |v| { + result.frame = v; + } + + if (kv.get('r')) |v| { + result.edit_frame = v; + } + + if (kv.get('x')) |v| { + result.x = v; + } + + if (kv.get('y')) |v| { + result.y = v; + } + + if (kv.get('w')) |v| { + result.width = v; + } + + if (kv.get('h')) |v| { + result.height = v; + } + + if (kv.get('X')) |v| { + result.left_edge = v; + } + + if (kv.get('Y')) |v| { + result.top_edge = v; + } + + if (kv.get('C')) |v| { + result.composition_mode = switch (v) { + 0 => .alpha_blend, + 1 => .overwrite, + else => return error.InvalidFormat, + }; + } + + return result; + } +}; + +pub const AnimationControl = struct { + action: AnimationAction = .invalid, // s + frame: u32 = 0, // r + gap_ms: u32 = 0, // z + current_frame: u32 = 0, // c + loops: u32 = 0, // v + + pub const AnimationAction = enum { + invalid, // 0 + stop, // 1 + run_wait, // 2 + run, // 3 + }; + + fn parse(kv: KV) !AnimationControl { + var result: AnimationControl = .{}; + + if (kv.get('s')) |v| { + result.action = switch (v) { + 0 => .invalid, + 1 => .stop, + 2 => .run_wait, + 3 => .run, + else => return error.InvalidFormat, + }; + } + + if (kv.get('r')) |v| { + result.frame = v; + } + + if (kv.get('z')) |v| { + result.gap_ms = v; + } + + if (kv.get('c')) |v| { + result.current_frame = v; + } + + if (kv.get('v')) |v| { + result.loops = v; + } + + return result; + } +}; + +pub const Delete = union(enum) { + // a/A + all: bool, + + // i/I + id: struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + placement_id: u32 = 0, // p + }, + + // n/N + newest: struct { + delete: bool = false, // uppercase + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + }, + + // c/C, + intersect_cursor: bool, + + // f/F + animation_frames: bool, + + // p/P + intersect_cell: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + }, + + // q/Q + intersect_cell_z: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + z: i32 = 0, // z + }, + + // x/X + column: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + }, + + // y/Y + row: struct { + delete: bool = false, // uppercase + y: u32 = 0, // y + }, + + // z/Z + z: struct { + delete: bool = false, // uppercase + z: i32 = 0, // z + }, + + fn parse(kv: KV) !Delete { + const what: u8 = what: { + const value = kv.get('d') orelse break :what 'a'; + const c = std.math.cast(u8, value) orelse return error.InvalidFormat; + break :what c; + }; + + return switch (what) { + 'a', 'A' => .{ .all = what == 'A' }, + + 'i', 'I' => blk: { + var result: Delete = .{ .id = .{ .delete = what == 'I' } }; + if (kv.get('i')) |v| { + result.id.image_id = v; + } + if (kv.get('p')) |v| { + result.id.placement_id = v; + } + + break :blk result; + }, + + 'n', 'N' => blk: { + var result: Delete = .{ .newest = .{ .delete = what == 'N' } }; + if (kv.get('I')) |v| { + result.newest.image_number = v; + } + if (kv.get('p')) |v| { + result.newest.placement_id = v; + } + + break :blk result; + }, + + 'c', 'C' => .{ .intersect_cursor = what == 'C' }, + + 'f', 'F' => .{ .animation_frames = what == 'F' }, + + 'p', 'P' => blk: { + var result: Delete = .{ .intersect_cell = .{ .delete = what == 'P' } }; + if (kv.get('x')) |v| { + result.intersect_cell.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell.y = v; + } + + break :blk result; + }, + + 'q', 'Q' => blk: { + var result: Delete = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; + if (kv.get('x')) |v| { + result.intersect_cell_z.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell_z.y = v; + } + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.intersect_cell_z.z = @bitCast(v); + } + + break :blk result; + }, + + 'x', 'X' => blk: { + var result: Delete = .{ .column = .{ .delete = what == 'X' } }; + if (kv.get('x')) |v| { + result.column.x = v; + } + + break :blk result; + }, + + 'y', 'Y' => blk: { + var result: Delete = .{ .row = .{ .delete = what == 'Y' } }; + if (kv.get('y')) |v| { + result.row.y = v; + } + + break :blk result; + }, + + 'z', 'Z' => blk: { + var result: Delete = .{ .z = .{ .delete = what == 'Z' } }; + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.z.z = @bitCast(v); + } + + break :blk result; + }, + + else => return error.InvalidFormat, + }; + } +}; + +pub const CompositionMode = enum { + alpha_blend, // 0 + overwrite, // 1 +}; + +test "transmission command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=20"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + +test "query command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .query); + const v = command.control.query; + try testing.expectEqual(Transmission.Medium.direct, v.medium); + try testing.expectEqual(@as(u32, 1), v.width); + try testing.expectEqual(@as(u32, 1), v.height); + try testing.expectEqual(@as(u32, 31), v.image_id); + try testing.expectEqualStrings("AAAA", command.data); +} + +test "display command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "a=p,U=1,i=31,c=80,r=120"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(@as(u32, 80), v.columns); + try testing.expectEqual(@as(u32, 120), v.rows); + try testing.expectEqual(@as(u32, 31), v.image_id); +} + +test "delete command" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "a=d,d=p,x=3,y=4"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete; + try testing.expect(v == .intersect_cell); + const dv = v.intersect_cell; + try testing.expect(!dv.delete); + try testing.expectEqual(@as(u32, 3), dv.x); + try testing.expectEqual(@as(u32, 4), dv.y); +} + +test "ignore unknown keys (long)" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=20,hello=world"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 20), v.height); +} + +test "ignore very long values" { + const testing = std.testing; + const alloc = testing.allocator; + var p = CommandParser.init(alloc); + defer p.deinit(); + + const input = "f=24,s=10,v=2000000000000000000000000000000000000000"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expectEqual(Transmission.Format.rgb, v.format); + try testing.expectEqual(@as(u32, 10), v.width); + try testing.expectEqual(@as(u32, 0), v.height); +} + +test "response: encode nothing without ID or image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{}; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("", fbs.getWritten()); +} + +test "response: encode with only image id" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .id = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_Gi=4;OK\x1b\\", fbs.getWritten()); +} + +test "response: encode with only image number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .image_number = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_GI=4;OK\x1b\\", fbs.getWritten()); +} + +test "response: encode with image ID and number" { + const testing = std.testing; + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + var r: Response = .{ .id = 12, .image_number = 4 }; + try r.encode(fbs.writer()); + try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", fbs.getWritten()); +} diff --git a/src/terminal2/kitty/graphics_exec.zig b/src/terminal2/kitty/graphics_exec.zig new file mode 100644 index 0000000000..b4047c1d5e --- /dev/null +++ b/src/terminal2/kitty/graphics_exec.zig @@ -0,0 +1,344 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const renderer = @import("../../renderer.zig"); +const point = @import("../point.zig"); +const Terminal = @import("../Terminal.zig"); +const command = @import("graphics_command.zig"); +const image = @import("graphics_image.zig"); +const Command = command.Command; +const Response = command.Response; +const LoadingImage = image.LoadingImage; +const Image = image.Image; +const ImageStorage = @import("graphics_storage.zig").ImageStorage; + +const log = std.log.scoped(.kitty_gfx); + +/// Execute a Kitty graphics command against the given terminal. This +/// will never fail, but the response may indicate an error and the +/// terminal state may not be updated to reflect the command. This will +/// never put the terminal in an unrecoverable state, however. +/// +/// The allocator must be the same allocator that was used to build +/// the command. +pub fn execute( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) ?Response { + // If storage is disabled then we disable the full protocol. This means + // we don't even respond to queries so the terminal completely acts as + // if this feature is not supported. + if (!terminal.screen.kitty_images.enabled()) { + log.debug("kitty graphics requested but disabled", .{}); + return null; + } + + log.debug("executing kitty graphics command: quiet={} control={}", .{ + cmd.quiet, + cmd.control, + }); + + const resp_: ?Response = switch (cmd.control) { + .query => query(alloc, cmd), + .transmit, .transmit_and_display => transmit(alloc, terminal, cmd), + .display => display(alloc, terminal, cmd), + .delete => delete(alloc, terminal, cmd), + + .transmit_animation_frame, + .control_animation, + .compose_animation, + => .{ .message = "ERROR: unimplemented action" }, + }; + + // Handle the quiet settings + if (resp_) |resp| { + if (!resp.ok()) { + log.warn("erroneous kitty graphics response: {s}", .{resp.message}); + } + + return switch (cmd.quiet) { + .no => resp, + .ok => if (resp.ok()) null else resp, + .failures => null, + }; + } + + return null; +} +/// Execute a "query" command. +/// +/// This command is used to attempt to load an image and respond with +/// success/error but does not persist any of the command to the terminal +/// state. +fn query(alloc: Allocator, cmd: *Command) Response { + const t = cmd.control.query; + + // Query requires image ID. We can't actually send a response without + // an image ID either but we return an error and this will be logged + // downstream. + if (t.image_id == 0) { + return .{ .message = "EINVAL: image ID required" }; + } + + // Build a partial response to start + var result: Response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }; + + // Attempt to load the image. If we cannot, then set an appropriate error. + var loading = LoadingImage.init(alloc, cmd) catch |err| { + encodeError(&result, err); + return result; + }; + loading.deinit(alloc); + + return result; +} + +/// Transmit image data. +/// +/// This loads the image, validates it, and puts it into the terminal +/// screen storage. It does not display the image. +fn transmit( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) Response { + const t = cmd.transmission().?; + var result: Response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }; + if (t.image_id > 0 and t.image_number > 0) { + return .{ .message = "EINVAL: image ID and number are mutually exclusive" }; + } + + const load = loadAndAddImage(alloc, terminal, cmd) catch |err| { + encodeError(&result, err); + return result; + }; + errdefer load.image.deinit(alloc); + + // If we're also displaying, then do that now. This function does + // both transmit and transmit and display. The display might also be + // deferred if it is multi-chunk. + if (load.display) |d| { + assert(!load.more); + var d_copy = d; + d_copy.image_id = load.image.id; + return display(alloc, terminal, &.{ + .control = .{ .display = d_copy }, + .quiet = cmd.quiet, + }); + } + + // If there are more chunks expected we do not respond. + if (load.more) return .{}; + + // After the image is added, set the ID in case it changed + result.id = load.image.id; + + // If the original request had an image number, then we respond. + // Otherwise, we don't respond. + if (load.image.number == 0) return .{}; + + return result; +} + +/// Display a previously transmitted image. +fn display( + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const d = cmd.display().?; + + // Display requires image ID or number. + if (d.image_id == 0 and d.image_number == 0) { + return .{ .message = "EINVAL: image ID or number required" }; + } + + // Build up our response + var result: Response = .{ + .id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }; + + // Verify the requested image exists if we have an ID + const storage = &terminal.screen.kitty_images; + const img_: ?Image = if (d.image_id != 0) + storage.imageById(d.image_id) + else + storage.imageByNumber(d.image_number); + const img = img_ orelse { + result.message = "EINVAL: image not found"; + return result; + }; + + // Make sure our response has the image id in case we looked up by number + result.id = img.id; + + // Determine the screen point for the placement. + const placement_point = (point.Viewport{ + .x = terminal.screen.cursor.x, + .y = terminal.screen.cursor.y, + }).toScreen(&terminal.screen); + + // Add the placement + const p: ImageStorage.Placement = .{ + .point = placement_point, + .x_offset = d.x_offset, + .y_offset = d.y_offset, + .source_x = d.x, + .source_y = d.y, + .source_width = d.width, + .source_height = d.height, + .columns = d.columns, + .rows = d.rows, + .z = d.z, + }; + storage.addPlacement( + alloc, + img.id, + result.placement_id, + p, + ) catch |err| { + encodeError(&result, err); + return result; + }; + + // Cursor needs to move after placement + switch (d.cursor_movement) { + .none => {}, + .after => { + const rect = p.rect(img, terminal); + + // We can do better by doing this with pure internal screen state + // but this handles scroll regions. + const height = rect.bottom_right.y - rect.top_left.y; + for (0..height) |_| terminal.index() catch |err| { + log.warn("failed to move cursor: {}", .{err}); + break; + }; + + terminal.setCursorPos( + terminal.screen.cursor.y, + rect.bottom_right.x + 1, + ); + }, + } + + // Display does not result in a response on success + return .{}; +} + +/// Display a previously transmitted image. +fn delete( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) Response { + const storage = &terminal.screen.kitty_images; + storage.delete(alloc, terminal, cmd.control.delete); + + // Delete never responds on success + return .{}; +} + +fn loadAndAddImage( + alloc: Allocator, + terminal: *Terminal, + cmd: *Command, +) !struct { + image: Image, + more: bool = false, + display: ?command.Display = null, +} { + const t = cmd.transmission().?; + const storage = &terminal.screen.kitty_images; + + // Determine our image. This also handles chunking and early exit. + var loading: LoadingImage = if (storage.loading) |loading| loading: { + // Note: we do NOT want to call "cmd.toOwnedData" here because + // we're _copying_ the data. We want the command data to be freed. + try loading.addData(alloc, cmd.data); + + // If we have more then we're done + if (t.more_chunks) return .{ .image = loading.image, .more = true }; + + // We have no more chunks. We're going to be completing the + // image so we want to destroy the pointer to the loading + // image and copy it out. + defer { + alloc.destroy(loading); + storage.loading = null; + } + + break :loading loading.*; + } else try LoadingImage.init(alloc, cmd); + + // We only want to deinit on error. If we're chunking, then we don't + // want to deinit at all. If we're not chunking, then we'll deinit + // after we've copied the image out. + errdefer loading.deinit(alloc); + + // If the image has no ID, we assign one + if (loading.image.id == 0) { + loading.image.id = storage.next_image_id; + storage.next_image_id +%= 1; + } + + // If this is chunked, this is the beginning of a new chunked transmission. + // (We checked for an in-progress chunk above.) + if (t.more_chunks) { + // We allocate the pointer on the heap because its rare and we + // don't want to always pay the memory cost to keep it around. + const loading_ptr = try alloc.create(LoadingImage); + errdefer alloc.destroy(loading_ptr); + loading_ptr.* = loading; + storage.loading = loading_ptr; + return .{ .image = loading.image, .more = true }; + } + + // Dump the image data before it is decompressed + // loading.debugDump() catch unreachable; + + // Validate and store our image + var img = try loading.complete(alloc); + errdefer img.deinit(alloc); + try storage.addImage(alloc, img); + + // Get our display settings + const display_ = loading.display; + + // Ensure we deinit the loading state because we're done. The image + // won't be deinit because of "complete" above. + loading.deinit(alloc); + + return .{ .image = img, .display = display_ }; +} + +const EncodeableError = Image.Error || Allocator.Error; + +/// Encode an error code into a message for a response. +fn encodeError(r: *Response, err: EncodeableError) void { + switch (err) { + error.OutOfMemory => r.message = "ENOMEM: out of memory", + error.InternalError => r.message = "EINVAL: internal error", + error.InvalidData => r.message = "EINVAL: invalid data", + error.DecompressionFailed => r.message = "EINVAL: decompression failed", + error.FilePathTooLong => r.message = "EINVAL: file path too long", + error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", + error.UnsupportedFormat => r.message = "EINVAL: unsupported format", + error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", + error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", + error.DimensionsRequired => r.message = "EINVAL: dimensions required", + error.DimensionsTooLarge => r.message = "EINVAL: dimensions too large", + } +} diff --git a/src/terminal2/kitty/graphics_image.zig b/src/terminal2/kitty/graphics_image.zig new file mode 100644 index 0000000000..d84ea91d61 --- /dev/null +++ b/src/terminal2/kitty/graphics_image.zig @@ -0,0 +1,776 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const command = @import("graphics_command.zig"); +const point = @import("../point.zig"); +const internal_os = @import("../../os/main.zig"); +const stb = @import("../../stb/main.zig"); + +const log = std.log.scoped(.kitty_gfx); + +/// Maximum width or height of an image. Taken directly from Kitty. +const max_dimension = 10000; + +/// Maximum size in bytes, taken from Kitty. +const max_size = 400 * 1024 * 1024; // 400MB + +/// An image that is still being loaded. The image should be initialized +/// using init on the first chunk and then addData for each subsequent +/// chunk. Once all chunks have been added, complete should be called +/// to finalize the image. +pub const LoadingImage = struct { + /// The in-progress image. The first chunk must have all the metadata + /// so this comes from that initially. + image: Image, + + /// The data that is being built up. + data: std.ArrayListUnmanaged(u8) = .{}, + + /// This is non-null when a transmit and display command is given + /// so that we display the image after it is fully loaded. + display: ?command.Display = null, + + /// Initialize a chunked immage from the first image transmission. + /// If this is a multi-chunk image, this should only be the FIRST + /// chunk. + pub fn init(alloc: Allocator, cmd: *command.Command) !LoadingImage { + // Build our initial image from the properties sent via the control. + // These can be overwritten by the data loading process. For example, + // PNG loading sets the width/height from the data. + const t = cmd.transmission().?; + var result: LoadingImage = .{ + .image = .{ + .id = t.image_id, + .number = t.image_number, + .width = t.width, + .height = t.height, + .compression = t.compression, + .format = t.format, + }, + + .display = cmd.display(), + }; + + // Special case for the direct medium, we just add it directly + // which will handle copying the data, base64 decoding, etc. + if (t.medium == .direct) { + try result.addData(alloc, cmd.data); + return result; + } + + // For every other medium, we'll need to at least base64 decode + // the data to make it useful so let's do that. Also, all the data + // has to be path data so we can put it in a stack-allocated buffer. + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const Base64Decoder = std.base64.standard.Decoder; + const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| { + log.warn("failed to calculate base64 size for file path: {}", .{err}); + return error.InvalidData; + }; + if (size > buf.len) return error.FilePathTooLong; + Base64Decoder.decode(&buf, cmd.data) catch |err| { + log.warn("failed to decode base64 data: {}", .{err}); + return error.InvalidData; + }; + + if (comptime builtin.os.tag != .windows) { + if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) { + // std.os.realpath *asserts* that the path does not have + // internal nulls instead of erroring. + log.warn("failed to get absolute path: BadPathName", .{}); + return error.InvalidData; + } + } + + var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = std.os.realpath(buf[0..size], &abs_buf) catch |err| { + log.warn("failed to get absolute path: {}", .{err}); + return error.InvalidData; + }; + + // Depending on the medium, load the data from the path. + switch (t.medium) { + .direct => unreachable, // handled above + .file => try result.readFile(.file, alloc, t, path), + .temporary_file => try result.readFile(.temporary_file, alloc, t, path), + .shared_memory => try result.readSharedMemory(alloc, t, path), + } + + return result; + } + + /// Reads the data from a shared memory segment. + fn readSharedMemory( + self: *LoadingImage, + alloc: Allocator, + t: command.Transmission, + path: []const u8, + ) !void { + // We require libc for this for shm_open + if (comptime !builtin.link_libc) return error.UnsupportedMedium; + + // Todo: support shared memory + _ = self; + _ = alloc; + _ = t; + _ = path; + return error.UnsupportedMedium; + } + + /// Reads the data from a temporary file and returns it. This allocates + /// and does not free any of the data, so the caller must free it. + /// + /// This will also delete the temporary file if it is in a safe location. + fn readFile( + self: *LoadingImage, + comptime medium: command.Transmission.Medium, + alloc: Allocator, + t: command.Transmission, + path: []const u8, + ) !void { + switch (medium) { + .file, .temporary_file => {}, + else => @compileError("readFile only supports file and temporary_file"), + } + + // Verify file seems "safe". This is logic copied directly from Kitty, + // mostly. This is really rough but it will catch obvious bad actors. + if (std.mem.startsWith(u8, path, "/proc/") or + std.mem.startsWith(u8, path, "/sys/") or + (std.mem.startsWith(u8, path, "/dev/") and + !std.mem.startsWith(u8, path, "/dev/shm/"))) + { + return error.InvalidData; + } + + // Temporary file logic + if (medium == .temporary_file) { + if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; + } + defer if (medium == .temporary_file) { + std.os.unlink(path) catch |err| { + log.warn("failed to delete temporary file: {}", .{err}); + }; + }; + + var file = std.fs.cwd().openFile(path, .{}) catch |err| { + log.warn("failed to open temporary file: {}", .{err}); + return error.InvalidData; + }; + defer file.close(); + + // File must be a regular file + if (file.stat()) |stat| { + if (stat.kind != .file) { + log.warn("file is not a regular file kind={}", .{stat.kind}); + return error.InvalidData; + } + } else |err| { + log.warn("failed to stat file: {}", .{err}); + return error.InvalidData; + } + + if (t.offset > 0) { + file.seekTo(@intCast(t.offset)) catch |err| { + log.warn("failed to seek to offset {}: {}", .{ t.offset, err }); + return error.InvalidData; + }; + } + + var buf_reader = std.io.bufferedReader(file.reader()); + const reader = buf_reader.reader(); + + // Read the file + var managed = std.ArrayList(u8).init(alloc); + errdefer managed.deinit(); + const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size; + reader.readAllArrayList(&managed, size) catch |err| { + log.warn("failed to read temporary file: {}", .{err}); + return error.InvalidData; + }; + + // Set our data + assert(self.data.items.len == 0); + self.data = .{ .items = managed.items, .capacity = managed.capacity }; + } + + /// Returns true if path appears to be in a temporary directory. + /// Copies logic from Kitty. + fn isPathInTempDir(path: []const u8) bool { + if (std.mem.startsWith(u8, path, "/tmp")) return true; + if (std.mem.startsWith(u8, path, "/dev/shm")) return true; + if (internal_os.allocTmpDir(std.heap.page_allocator)) |dir| { + defer internal_os.freeTmpDir(std.heap.page_allocator, dir); + if (std.mem.startsWith(u8, path, dir)) return true; + + // The temporary dir is sometimes a symlink. On macOS for + // example /tmp is /private/var/... + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + if (std.os.realpath(dir, &buf)) |real_dir| { + if (std.mem.startsWith(u8, path, real_dir)) return true; + } else |_| {} + } + + return false; + } + + pub fn deinit(self: *LoadingImage, alloc: Allocator) void { + self.image.deinit(alloc); + self.data.deinit(alloc); + } + + pub fn destroy(self: *LoadingImage, alloc: Allocator) void { + self.deinit(alloc); + alloc.destroy(self); + } + + /// Adds a chunk of base64-encoded data to the image. Use this if the + /// image is coming in chunks (the "m" parameter in the protocol). + pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void { + // If no data, skip + if (data.len == 0) return; + + // Grow our array list by size capacity if it needs it + const Base64Decoder = std.base64.standard.Decoder; + const size = Base64Decoder.calcSizeForSlice(data) catch |err| { + log.warn("failed to calculate size for base64 data: {}", .{err}); + return error.InvalidData; + }; + + // If our data would get too big, return an error + if (self.data.items.len + size > max_size) { + log.warn("image data too large max_size={}", .{max_size}); + return error.InvalidData; + } + + try self.data.ensureUnusedCapacity(alloc, size); + + // We decode directly into the arraylist + const start_i = self.data.items.len; + self.data.items.len = start_i + size; + const buf = self.data.items[start_i..]; + Base64Decoder.decode(buf, data) catch |err| switch (err) { + // We have to ignore invalid padding because lots of encoders + // add the wrong padding. Since we validate image data later + // (PNG decode or simple dimensions check), we can ignore this. + error.InvalidPadding => {}, + + else => { + log.warn("failed to decode base64 data: {}", .{err}); + return error.InvalidData; + }, + }; + } + + /// Complete the chunked image, returning a completed image. + pub fn complete(self: *LoadingImage, alloc: Allocator) !Image { + const img = &self.image; + + // Decompress the data if it is compressed. + try self.decompress(alloc); + + // Decode the png if we have to + if (img.format == .png) try self.decodePng(alloc); + + // Validate our dimensions. + if (img.width == 0 or img.height == 0) return error.DimensionsRequired; + if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; + + // Data length must be what we expect + const bpp: u32 = switch (img.format) { + .grey_alpha => 2, + .rgb => 3, + .rgba => 4, + .png => unreachable, // png should be decoded by here + }; + const expected_len = img.width * img.height * bpp; + const actual_len = self.data.items.len; + if (actual_len != expected_len) { + std.log.warn( + "unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}", + .{ img.id, img.width, img.height, bpp, expected_len, actual_len }, + ); + return error.InvalidData; + } + + // Set our time + self.image.transmit_time = std.time.Instant.now() catch |err| { + log.warn("failed to get time: {}", .{err}); + return error.InternalError; + }; + + // Everything looks good, copy the image data over. + var result = self.image; + result.data = try self.data.toOwnedSlice(alloc); + errdefer result.deinit(alloc); + self.image = .{}; + return result; + } + + /// Debug function to write the data to a file. This is useful for + /// capturing some test data for unit tests. + pub fn debugDump(self: LoadingImage) !void { + if (comptime builtin.mode != .Debug) @compileError("debugDump in non-debug"); + + var buf: [1024]u8 = undefined; + const filename = try std.fmt.bufPrint( + &buf, + "image-{s}-{s}-{d}x{d}-{}.data", + .{ + @tagName(self.image.format), + @tagName(self.image.compression), + self.image.width, + self.image.height, + self.image.id, + }, + ); + const cwd = std.fs.cwd(); + const f = try cwd.createFile(filename, .{}); + defer f.close(); + + const writer = f.writer(); + try writer.writeAll(self.data.items); + } + + /// Decompress the data in-place. + fn decompress(self: *LoadingImage, alloc: Allocator) !void { + return switch (self.image.compression) { + .none => {}, + .zlib_deflate => self.decompressZlib(alloc), + }; + } + + fn decompressZlib(self: *LoadingImage, alloc: Allocator) !void { + // Open our zlib stream + var fbs = std.io.fixedBufferStream(self.data.items); + var stream = std.compress.zlib.decompressor(fbs.reader()); + + // Write it to an array list + var list = std.ArrayList(u8).init(alloc); + errdefer list.deinit(); + stream.reader().readAllArrayList(&list, max_size) catch |err| { + log.warn("failed to read decompressed data: {}", .{err}); + return error.DecompressionFailed; + }; + + // Empty our current data list, take ownership over managed array list + self.data.deinit(alloc); + self.data = .{ .items = list.items, .capacity = list.capacity }; + + // Make sure we note that our image is no longer compressed + self.image.compression = .none; + } + + /// Decode the data as PNG. This will also updated the image dimensions. + fn decodePng(self: *LoadingImage, alloc: Allocator) !void { + assert(self.image.format == .png); + + // Decode PNG + var width: c_int = 0; + var height: c_int = 0; + var bpp: c_int = 0; + const data = stb.stbi_load_from_memory( + self.data.items.ptr, + @intCast(self.data.items.len), + &width, + &height, + &bpp, + 0, + ) orelse return error.InvalidData; + defer stb.stbi_image_free(data); + const len: usize = @intCast(width * height * bpp); + if (len > max_size) { + log.warn("png image too large size={} max_size={}", .{ len, max_size }); + return error.InvalidData; + } + + // Validate our bpp + if (bpp < 2 or bpp > 4) { + log.warn("png with unsupported bpp={}", .{bpp}); + return error.UnsupportedDepth; + } + + // Replace our data + self.data.deinit(alloc); + self.data = .{}; + try self.data.ensureUnusedCapacity(alloc, len); + try self.data.appendSlice(alloc, data[0..len]); + + // Store updated image dimensions + self.image.width = @intCast(width); + self.image.height = @intCast(height); + self.image.format = switch (bpp) { + 2 => .grey_alpha, + 3 => .rgb, + 4 => .rgba, + else => unreachable, // validated above + }; + } +}; + +/// Image represents a single fully loaded image. +pub const Image = struct { + id: u32 = 0, + number: u32 = 0, + width: u32 = 0, + height: u32 = 0, + format: command.Transmission.Format = .rgb, + compression: command.Transmission.Compression = .none, + data: []const u8 = "", + transmit_time: std.time.Instant = undefined, + + pub const Error = error{ + InternalError, + InvalidData, + DecompressionFailed, + DimensionsRequired, + DimensionsTooLarge, + FilePathTooLong, + TemporaryFileNotInTempDir, + UnsupportedFormat, + UnsupportedMedium, + UnsupportedDepth, + }; + + pub fn deinit(self: *Image, alloc: Allocator) void { + if (self.data.len > 0) alloc.free(self.data); + } + + /// Mostly for logging + pub fn withoutData(self: *const Image) Image { + var copy = self.*; + copy.data = ""; + return copy; + } +}; + +/// The rect taken up by some image placement, in grid cells. This will +/// 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; + } +}; + +/// Easy base64 encoding function. +fn testB64(alloc: Allocator, data: []const u8) ![]const u8 { + const B64Encoder = std.base64.standard.Encoder; + const b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len)); + errdefer alloc.free(b64); + return B64Encoder.encode(b64, data); +} + +/// Easy base64 decoding function. +fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 { + const B64Decoder = std.base64.standard.Decoder; + const result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data)); + errdefer alloc.free(result); + try B64Decoder.decode(result, data); + return result; +} + +// This specifically tests we ALLOW invalid RGB data because Kitty +// documents that this should work. +test "image load with invalid RGB data" { + const testing = std.testing; + const alloc = testing.allocator; + + // _Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\ + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); +} + +test "image load with image too wide" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .width = max_dimension + 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); +} + +test "image load with image too tall" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .height = max_dimension + 1, + .width = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); +} + +test "image load: rgb, zlib compressed, direct" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + } }, + .data = try alloc.dupe( + u8, + @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"), + ), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + + // should be decompressed + try testing.expect(img.compression == .none); +} + +test "image load: rgb, not compressed, direct" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe( + u8, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + + // should be decompressed + try testing.expect(img.compression == .none); +} + +test "image load: rgb, zlib compressed, direct, chunked" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); + + // Setup our initial chunk + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + .more_chunks = true, + } }, + .data = try alloc.dupe(u8, data[0..1024]), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + + // Read our remaining chunks + var fbs = std.io.fixedBufferStream(data[1024..]); + var buf: [1024]u8 = undefined; + while (fbs.reader().readAll(&buf)) |size| { + try loading.addData(alloc, buf[0..size]); + if (size < buf.len) break; + } else |err| return err; + + // Complete + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); +} + +test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); + + // Setup our initial chunk + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .compression = .zlib_deflate, + .height = 96, + .width = 128, + .image_id = 31, + .more_chunks = true, + } }, + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + + // Read our remaining chunks + var fbs = std.io.fixedBufferStream(data); + var buf: [1024]u8 = undefined; + while (fbs.reader().readAll(&buf)) |size| { + try loading.addData(alloc, buf[0..size]); + if (size < buf.len) break; + } else |err| return err; + + // Complete + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = try testB64Decode( + alloc, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ); + defer alloc.free(data); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + + // Temporary file should be gone + try testing.expectError(error.FileNotFound, tmp_dir.dir.access(path, .{})); +} + +test "image load: rgb, not compressed, regular file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = try testB64Decode( + alloc, + @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), + ); + defer alloc.free(data); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + try tmp_dir.dir.access(path, .{}); +} + +test "image load: png, not compressed, regular file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); + try tmp_dir.dir.writeFile("image.data", data); + + var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .png, + .medium = .file, + .compression = .none, + .width = 0, + .height = 0, + .image_id = 31, + } }, + .data = try testB64(alloc, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + try testing.expect(img.compression == .none); + try testing.expect(img.format == .rgb); + try tmp_dir.dir.access(path, .{}); +} diff --git a/src/terminal2/kitty/graphics_storage.zig b/src/terminal2/kitty/graphics_storage.zig new file mode 100644 index 0000000000..230c1edc3b --- /dev/null +++ b/src/terminal2/kitty/graphics_storage.zig @@ -0,0 +1,919 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +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 log = std.log.scoped(.kitty_gfx); + +/// An image storage is associated with a terminal screen (i.e. main +/// screen, alt screen) and contains all the transmitted images and +/// placements. +pub const ImageStorage = struct { + const ImageMap = std.AutoHashMapUnmanaged(u32, Image); + const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement); + + /// Dirty is set to true if placements or images change. This is + /// purely informational for the renderer and doesn't affect the + /// correctness of the program. The renderer must set this to false + /// if it cares about this value. + dirty: bool = false, + + /// This is the next automatically assigned image ID. We start mid-way + /// through the u32 range to avoid collisions with buggy programs. + next_image_id: u32 = 2147483647, + + /// This is the next automatically assigned placement ID. This is never + /// user-facing so we can start at 0. This is 32-bits because we use + /// the same space for external placement IDs. We can start at zero + /// because any number is valid. + next_internal_placement_id: u32 = 0, + + /// The set of images that are currently known. + images: ImageMap = .{}, + + /// The set of placements for loaded images. + placements: PlacementMap = .{}, + + /// Non-null if there is an in-progress loading image. + loading: ?*LoadingImage = null, + + /// The total bytes of image data that have been loaded and the limit. + /// If the limit is reached, the oldest images will be evicted to make + /// space. Unused images take priority. + total_bytes: usize = 0, + total_limit: usize = 320 * 1000 * 1000, // 320MB + + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + ) 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(t); + self.placements.deinit(alloc); + } + + /// Kitty image protocol is enabled if we have a non-zero limit. + pub fn enabled(self: *const ImageStorage) bool { + return self.total_limit != 0; + } + + /// Sets the limit in bytes for the total amount of image data that + /// can be loaded. If this limit is lower, this will do an eviction + /// if necessary. If the value is zero, then Kitty image protocol will + /// be disabled. + pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void { + // Special case disabling by quickly deleting all + if (limit == 0) { + self.deinit(alloc); + self.* = .{}; + } + + // If we re lowering our limit, check if we need to evict. + if (limit < self.total_bytes) { + const req_bytes = self.total_bytes - limit; + log.info("evicting images to lower limit, evicting={}", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + } + } + + self.total_limit = limit; + } + + /// Add an already-loaded image to the storage. This will automatically + /// free any existing image with the same ID. + pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void { + // If the image itself is over the limit, then error immediately + if (img.data.len > self.total_limit) return error.OutOfMemory; + + // If this would put us over the limit, then evict. + const total_bytes = self.total_bytes + img.data.len; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + log.info("evicting images to make space for {} bytes", .{req_bytes}); + if (!try self.evictImage(alloc, req_bytes)) { + log.warn("failed to evict enough images for required bytes", .{}); + return error.OutOfMemory; + } + } + + // Do the gop op first so if it fails we don't get a partial state + const gop = try self.images.getOrPut(alloc, img.id); + + log.debug("addImage image={}", .{img: { + var copy = img; + copy.data = ""; + break :img copy; + }}); + + // Write our new image + if (gop.found_existing) { + self.total_bytes -= gop.value_ptr.data.len; + gop.value_ptr.deinit(alloc); + } + + gop.value_ptr.* = img; + self.total_bytes += img.data.len; + + self.dirty = true; + } + + /// Add a placement for a given image. The caller must verify in advance + /// the image exists to prevent memory corruption. + pub fn addPlacement( + self: *ImageStorage, + alloc: Allocator, + image_id: u32, + placement_id: u32, + p: Placement, + ) !void { + assert(self.images.get(image_id) != null); + log.debug("placement image_id={} placement_id={} placement={}\n", .{ + image_id, + placement_id, + p, + }); + + // The important piece here is that the placement ID needs to + // be marked internal if it is zero. This allows multiple placements + // to be added for the same image. If it is non-zero, then it is + // an external placement ID and we can only have one placement + // per (image id, placement id) pair. + const key: PlacementKey = .{ + .image_id = image_id, + .placement_id = if (placement_id == 0) .{ + .tag = .internal, + .id = id: { + defer self.next_internal_placement_id +%= 1; + break :id self.next_internal_placement_id; + }, + } else .{ + .tag = .external, + .id = placement_id, + }, + }; + + const gop = try self.placements.getOrPut(alloc, key); + gop.value_ptr.* = p; + + self.dirty = true; + } + + fn clearPlacements(self: *ImageStorage, t: *terminal.Terminal) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(t); + 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); + } + + /// Get an image by its number. If the image doesn't exist, return null. + pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image { + var newest: ?Image = null; + + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number == image_number) { + if (newest == null or + kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + { + newest = kv.value_ptr.*; + } + } + } + + return newest; + } + + /// Delete placements, images. + pub fn delete( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + cmd: command.Delete, + ) void { + switch (cmd) { + .all => |delete_images| if (delete_images) { + // We just reset our entire state. + self.deinit(alloc, t); + self.* = .{ + .dirty = true, + .total_limit = self.total_limit, + }; + } else { + // Delete all our placements + self.clearPlacements(t); + self.placements.deinit(alloc); + self.placements = .{}; + self.dirty = true; + }, + + .id => |v| self.deleteById( + alloc, + t, + v.image_id, + v.placement_id, + v.delete, + ), + + .newest => |v| newest: { + if (true) @panic("TODO"); + const img = self.imageByNumber(v.image_number) orelse break :newest; + self.deleteById(alloc, img.id, v.placement_id, v.delete); + }, + + .intersect_cursor => |delete_images| { + if (true) @panic("TODO"); + 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| { + if (true) @panic("TODO"); + 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| { + if (true) @panic("TODO"); + 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| { + if (true) @panic("TODO"); + 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.x <= v.x and rect.bottom_right.x >= v.x) { + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .row => |v| { + if (true) @panic("TODO"); + // 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); + 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); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + .z => |v| { + if (true) @panic("TODO"); + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.z == v.z) { + const image_id = entry.key_ptr.image_id; + self.placements.removeByPtr(entry.key_ptr); + if (v.delete) self.deleteIfUnused(alloc, image_id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + }, + + // We don't support animation frames yet so they are successfully + // deleted! + .animation_frames => {}, + } + } + + fn deleteById( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + image_id: u32, + placement_id: u32, + delete_unused: bool, + ) void { + // If no placement, we delete all placements with the ID + if (placement_id == 0) { + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(t); + self.placements.removeByPtr(entry.key_ptr); + } + } + } else { + if (self.placements.getEntry(.{ + .image_id = image_id, + .placement_id = .{ .tag = .external, .id = placement_id }, + })) |entry| { + entry.value_ptr.deinit(t); + self.placements.removeByPtr(entry.key_ptr); + } + } + + // If this is specified, then we also delete the image + // if it is no longer in use. + if (delete_unused) self.deleteIfUnused(alloc, image_id); + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Delete an image if it is unused. + fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { + var it = self.placements.iterator(); + while (it.next()) |kv| { + if (kv.key_ptr.image_id == image_id) { + return; + } + } + + // If we get here, we can delete the image. + if (self.images.getEntry(image_id)) |entry| { + self.total_bytes -= entry.value_ptr.data.len; + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + } + } + + /// Deletes all placements intersecting a screen point. + fn deleteIntersecting( + self: *ImageStorage, + alloc: Allocator, + t: *const terminal.Terminal, + p: point.ScreenPoint, + delete_unused: bool, + filter_ctx: anytype, + comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, + ) void { + 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 (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + self.placements.removeByPtr(entry.key_ptr); + if (delete_unused) self.deleteIfUnused(alloc, img.id); + } + } + + // Mark dirty to force redraw + self.dirty = true; + } + + /// Evict image to make space. This will evict the oldest image, + /// prioritizing unused images first, as recommended by the published + /// Kitty spec. + /// + /// This will evict as many images as necessary to make space for + /// req bytes. + fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool { + assert(req <= self.total_limit); + + // Ironically we allocate to evict. We should probably redesign the + // data structures to avoid this but for now allocating a little + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, + time: std.time.Instant, + used: bool, + }; + + var candidates = std.ArrayList(Candidate).init(alloc); + defer candidates.deinit(); + + var it = self.images.iterator(); + while (it.next()) |kv| { + const img = kv.value_ptr; + + // This is a huge waste. See comment above about redesigning + // our data structures to avoid this. Eviction should be very + // rare though and we never have that many images/placements + // so hopefully this will last a long time. + const used = used: { + var p_it = self.placements.iterator(); + while (p_it.next()) |p_kv| { + if (p_kv.key_ptr.image_id == img.id) { + break :used true; + } + } + + break :used false; + }; + + try candidates.append(.{ + .id = img.id, + .time = img.transmit_time, + .used = used, + }); + } + + // Sort + std.mem.sortUnstable( + Candidate, + candidates.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Candidate, + rhs: Candidate, + ) bool { + _ = ctx; + + // If they're usage matches, then its based on time. + if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { + .lt => true, + .gt => false, + .eq => lhs.id < rhs.id, + }; + + // If not used, then its a better candidate + return !lhs.used; + } + }.lessThan, + ); + + // They're in order of best to evict. + var evicted: usize = 0; + for (candidates.items) |c| { + // Delete all the placements for this image and the image. + var p_it = self.placements.iterator(); + while (p_it.next()) |entry| { + if (entry.key_ptr.image_id == c.id) { + self.placements.removeByPtr(entry.key_ptr); + } + } + + if (self.images.getEntry(c.id)) |entry| { + log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); + + evicted += entry.value_ptr.data.len; + self.total_bytes -= entry.value_ptr.data.len; + + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + + if (evicted > req) return true; + } + } + + return false; + } + + /// Every placement is uniquely identified by the image ID and the + /// placement ID. If an image ID isn't specified it is assumed to be 0. + /// Likewise, if a placement ID isn't specified it is assumed to be 0. + pub const PlacementKey = struct { + image_id: u32, + placement_id: packed struct { + tag: enum(u1) { internal, external }, + id: u32, + }, + }; + + pub const Placement = struct { + /// 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, + y_offset: u32 = 0, + + /// Source rectangle for the image to pull from + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, + + /// The columns/rows this image occupies. + columns: u32 = 0, + rows: u32 = 0, + + /// The z-index for this placement. + z: i32 = 0, + + pub fn deinit( + self: *const Placement, + t: *terminal.Terminal, + ) void { + t.screen.pages.untrackPin(self.pin); + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) 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, + }, + }; + } + + // Calculate our cell size. + const terminal_width_f64: f64 = @floatFromInt(t.width_px); + const terminal_height_f64: f64 = @floatFromInt(t.height_px); + const grid_columns_f64: f64 = @floatFromInt(t.cols); + const grid_rows_f64: f64 = @floatFromInt(t.rows); + const cell_width_f64 = terminal_width_f64 / grid_columns_f64; + const cell_height_f64 = terminal_height_f64 / grid_rows_f64; + + // Our image width + const width_px = if (self.source_width > 0) self.source_width else image.width; + const height_px = if (self.source_height > 0) self.source_height else image.height; + + // Calculate our image size in grid cells + const width_f64: f64 = @floatFromInt(width_px); + const height_f64: f64 = @floatFromInt(height_px); + const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); + const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); + + return .{ + .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; + var t = try terminal.Terminal.init(alloc, 100, 100); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, &t); + 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 testing.expectEqual(@as(usize, 2), s.placements.count()); + try testing.expectEqual(@as(usize, 2), s.images.count()); + + // verify the placement is what we expect + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 0 }, + }) != null); + try testing.expect(s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .internal, .id = 1 }, + }) != null); +} + +test "storage: delete all placements and images" { + const testing = std.testing; + 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); + 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 }) }); + + 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" { + const testing = std.testing; + 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); + 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 }) }); + + 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(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); +} + +test "storage: delete all placements" { + const testing = std.testing; + 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); + 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 }) }); + + 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" { + const testing = std.testing; + 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); + 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 }) }); + + 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" { + const testing = std.testing; + 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); + 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 }) }); + + 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" { + const testing = std.testing; + 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); + 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 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 2, + } }); + 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" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// 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, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// 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()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 2 }, +// }) != null); +// } +// +// test "storage: delete intersecting cursor plus unused" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// 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, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// 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()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 2 }, +// }) != null); +// } +// +// test "storage: delete intersecting cursor hits multiple" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// 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, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// 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()); +// } +// +// test "storage: delete by column" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// 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, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .column = .{ +// .delete = false, +// .x = 60, +// } }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 1 }, +// }) != null); +// } +// +// test "storage: delete by row" { +// const testing = std.testing; +// const alloc = testing.allocator; +// var t = try terminal.Terminal.init(alloc, 100, 100); +// defer t.deinit(alloc); +// t.width_px = 100; +// t.height_px = 100; +// +// var s: ImageStorage = .{}; +// 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, .{ .point = .{ .x = 0, .y = 0 } }); +// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); +// +// s.dirty = false; +// s.delete(alloc, &t, .{ .row = .{ +// .delete = false, +// .y = 60, +// } }); +// try testing.expect(s.dirty); +// try testing.expectEqual(@as(usize, 1), s.placements.count()); +// try testing.expectEqual(@as(usize, 2), s.images.count()); +// +// // verify the placement is what we expect +// try testing.expect(s.placements.get(.{ +// .image_id = 1, +// .placement_id = .{ .tag = .external, .id = 1 }, +// }) != null); +// } diff --git a/src/terminal2/kitty/key.zig b/src/terminal2/kitty/key.zig new file mode 100644 index 0000000000..938bf65b5a --- /dev/null +++ b/src/terminal2/kitty/key.zig @@ -0,0 +1,151 @@ +//! Kitty keyboard protocol support. + +const std = @import("std"); + +/// Stack for the key flags. This implements the push/pop behavior +/// of the CSI > u and CSI < u sequences. We implement the stack as +/// fixed size to avoid heap allocation. +pub const KeyFlagStack = struct { + const len = 8; + + flags: [len]KeyFlags = .{.{}} ** len, + idx: u3 = 0, + + /// Return the current stack value + pub fn current(self: KeyFlagStack) KeyFlags { + return self.flags[self.idx]; + } + + /// Perform the "set" operation as described in the spec for + /// the CSI = u sequence. + pub fn set( + self: *KeyFlagStack, + mode: KeySetMode, + v: KeyFlags, + ) void { + switch (mode) { + .set => self.flags[self.idx] = v, + .@"or" => self.flags[self.idx] = @bitCast( + self.flags[self.idx].int() | v.int(), + ), + .not => self.flags[self.idx] = @bitCast( + self.flags[self.idx].int() & ~v.int(), + ), + } + } + + /// Push a new set of flags onto the stack. If the stack is full + /// then the oldest entry is evicted. + pub fn push(self: *KeyFlagStack, flags: KeyFlags) void { + // Overflow and wrap around if we're full, which evicts + // the oldest entry. + self.idx +%= 1; + self.flags[self.idx] = flags; + } + + /// Pop `n` entries from the stack. This will just wrap around + /// if `n` is greater than the amount in the stack. + pub fn pop(self: *KeyFlagStack, n: usize) void { + // If n is more than our length then we just reset the stack. + // This also avoids a DoS vector where a malicious client + // could send a huge number of pop commands to waste cpu. + if (n >= self.flags.len) { + self.idx = 0; + self.flags = .{.{}} ** len; + return; + } + + for (0..n) |_| { + self.flags[self.idx] = .{}; + self.idx -%= 1; + } + } + + // Make sure we the overflow works as expected + test { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.idx = stack.flags.len - 1; + stack.idx +%= 1; + try testing.expect(stack.idx == 0); + + stack.idx = 0; + stack.idx -%= 1; + try testing.expect(stack.idx == stack.flags.len - 1); + } +}; + +/// The possible flags for the Kitty keyboard protocol. +pub const KeyFlags = packed struct(u5) { + disambiguate: bool = false, + report_events: bool = false, + report_alternates: bool = false, + report_all: bool = false, + report_associated: bool = false, + + pub fn int(self: KeyFlags) u5 { + return @bitCast(self); + } + + // Its easy to get packed struct ordering wrong so this test checks. + test { + const testing = std.testing; + + try testing.expectEqual( + @as(u5, 0b1), + (KeyFlags{ .disambiguate = true }).int(), + ); + try testing.expectEqual( + @as(u5, 0b10), + (KeyFlags{ .report_events = true }).int(), + ); + } +}; + +/// The possible modes for setting the key flags. +pub const KeySetMode = enum { set, @"or", not }; + +test "KeyFlagStack: push pop" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.push(.{ .disambiguate = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); + + stack.pop(1); + try testing.expectEqual(KeyFlags{}, stack.current()); +} + +test "KeyFlagStack: pop big number" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.pop(100); + try testing.expectEqual(KeyFlags{}, stack.current()); +} + +test "KeyFlagStack: set" { + const testing = std.testing; + var stack: KeyFlagStack = .{}; + stack.set(.set, .{ .disambiguate = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); + + stack.set(.@"or", .{ .report_events = true }); + try testing.expectEqual( + KeyFlags{ + .disambiguate = true, + .report_events = true, + }, + stack.current(), + ); + + stack.set(.not, .{ .report_events = true }); + try testing.expectEqual( + KeyFlags{ .disambiguate = true }, + stack.current(), + ); +} diff --git a/src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data b/src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data new file mode 100644 index 0000000000000000000000000000000000000000..032cb07c722cfd7ee5dd701e3a7407ddcaafc565 GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^MnLSt$P6S!Z Date: Tue, 5 Mar 2024 14:35:05 -0800 Subject: [PATCH 182/428] terminal2: delete kitty by intersecting cursor --- src/terminal2/PageList.zig | 45 ++++++++- src/terminal2/kitty/graphics_image.zig | 13 +-- src/terminal2/kitty/graphics_storage.zig | 116 +++++++++++++---------- 3 files changed, 111 insertions(+), 63 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 02bcd82dab..3d634962f2 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1765,6 +1765,47 @@ pub const Pin = struct { return .{ .row = rac.row, .cell = rac.cell }; } + /// Returns true if this pin is between the top and bottom, inclusive. + // + // Note: this is primarily unit tested as part of the Kitty + // graphics deletion code. + pub fn isBetween(self: Pin, top: Pin, bottom: Pin) bool { + if (comptime std.debug.runtime_safety) { + if (top.page == bottom.page) { + // If top is bottom, must be ordered. + assert(top.y <= bottom.y); + if (top.y == bottom.y) { + assert(top.x <= bottom.x); + } + } else { + // If top is not bottom, top must be before bottom. + var page = top.page.next; + while (page) |p| : (page = p.next) { + if (p == bottom.page) break; + } else assert(false); + } + } + + if (self.page == top.page) { + if (self.y < top.y) return false; + if (self.y > top.y) return true; + return self.x >= top.x; + } + if (self.page == bottom.page) { + if (self.y > bottom.y) return false; + if (self.y < bottom.y) return true; + return self.x <= bottom.x; + } + + var page = top.page.next; + while (page) |p| : (page = p.next) { + if (p == bottom.page) break; + if (p == self.page) return true; + } + + return false; + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { @@ -1785,7 +1826,7 @@ pub const Pin = struct { /// Move the offset down n rows. If the offset goes beyond the /// end of the screen, return the overflow amount. - fn downOverflow(self: Pin, n: usize) union(enum) { + pub fn downOverflow(self: Pin, n: usize) union(enum) { offset: Pin, overflow: struct { end: Pin, @@ -1823,7 +1864,7 @@ pub const Pin = struct { /// Move the offset up n rows. If the offset goes beyond the /// start of the screen, return the overflow amount. - fn upOverflow(self: Pin, n: usize) union(enum) { + pub fn upOverflow(self: Pin, n: usize) union(enum) { offset: Pin, overflow: struct { end: Pin, diff --git a/src/terminal2/kitty/graphics_image.zig b/src/terminal2/kitty/graphics_image.zig index d84ea91d61..8f9a1b6668 100644 --- a/src/terminal2/kitty/graphics_image.zig +++ b/src/terminal2/kitty/graphics_image.zig @@ -6,6 +6,7 @@ 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"); @@ -451,16 +452,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/terminal2/kitty/graphics_storage.zig b/src/terminal2/kitty/graphics_storage.zig index 230c1edc3b..1501f4f23d 100644 --- a/src/terminal2/kitty/graphics_storage.zig +++ b/src/terminal2/kitty/graphics_storage.zig @@ -242,12 +242,17 @@ pub const ImageStorage = struct { }, .intersect_cursor => |delete_images| { - if (true) @panic("TODO"); - 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| { @@ -378,18 +383,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); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -547,13 +556,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. @@ -574,12 +583,16 @@ 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, }; } }; @@ -769,37 +782,38 @@ test "storage: delete placement by specific id" { try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); } -// test "storage: delete intersecting cursor" { -// const testing = std.testing; -// const alloc = testing.allocator; -// var t = try terminal.Terminal.init(alloc, 100, 100); -// defer t.deinit(alloc); -// t.width_px = 100; -// t.height_px = 100; -// -// var s: ImageStorage = .{}; -// 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, .{ .point = .{ .x = 0, .y = 0 } }); -// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); -// -// 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()); -// -// // verify the placement is what we expect -// try testing.expect(s.placements.get(.{ -// .image_id = 1, -// .placement_id = .{ .tag = .external, .id = 2 }, -// }) != null); -// } -// +test "storage: delete intersecting cursor" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + 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); + 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 }) }); + + 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(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + // test "storage: delete intersecting cursor plus unused" { // const testing = std.testing; // const alloc = testing.allocator; From ad051cf830d395597090830c98b5801f9b4986e0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 17:04:13 -0800 Subject: [PATCH 183/428] terminal2/kitty: tests pass --- src/terminal2/PageList.zig | 7 +- src/terminal2/kitty/graphics_storage.zig | 298 +++++++++++++---------- 2 files changed, 170 insertions(+), 135 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 3d634962f2..c7d8542741 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1788,7 +1788,12 @@ pub const Pin = struct { if (self.page == top.page) { if (self.y < top.y) return false; - if (self.y > top.y) return true; + if (self.y > top.y) { + return if (self.page == bottom.page) + self.y <= bottom.y + else + true; + } return self.x >= top.x; } if (self.page == bottom.page) { diff --git a/src/terminal2/kitty/graphics_storage.zig b/src/terminal2/kitty/graphics_storage.zig index 1501f4f23d..f6ce3f8089 100644 --- a/src/terminal2/kitty/graphics_storage.zig +++ b/src/terminal2/kitty/graphics_storage.zig @@ -256,28 +256,44 @@ pub const ImageStorage = struct { }, .intersect_cell => |v| { - if (true) @panic("TODO"); - 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| { - if (true) @panic("TODO"); - 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| { - if (true) @panic("TODO"); 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.x <= v.x and rect.bottom_right.x >= v.x) { + entry.value_ptr.deinit(t); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -287,16 +303,24 @@ pub const ImageStorage = struct { self.dirty = true; }, - .row => |v| { - if (true) @panic("TODO"); - // 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); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -307,11 +331,11 @@ pub const ImageStorage = struct { }, .z => |v| { - if (true) @panic("TODO"); var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(t); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -814,120 +838,126 @@ test "storage: delete intersecting cursor" { }) != null); } -// test "storage: delete intersecting cursor plus unused" { -// const testing = std.testing; -// const alloc = testing.allocator; -// var t = try terminal.Terminal.init(alloc, 100, 100); -// defer t.deinit(alloc); -// t.width_px = 100; -// t.height_px = 100; -// -// var s: ImageStorage = .{}; -// 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, .{ .point = .{ .x = 0, .y = 0 } }); -// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); -// -// 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()); -// -// // verify the placement is what we expect -// try testing.expect(s.placements.get(.{ -// .image_id = 1, -// .placement_id = .{ .tag = .external, .id = 2 }, -// }) != null); -// } -// -// test "storage: delete intersecting cursor hits multiple" { -// const testing = std.testing; -// const alloc = testing.allocator; -// var t = try terminal.Terminal.init(alloc, 100, 100); -// defer t.deinit(alloc); -// t.width_px = 100; -// t.height_px = 100; -// -// var s: ImageStorage = .{}; -// 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, .{ .point = .{ .x = 0, .y = 0 } }); -// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); -// -// 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()); -// } -// -// test "storage: delete by column" { -// const testing = std.testing; -// const alloc = testing.allocator; -// var t = try terminal.Terminal.init(alloc, 100, 100); -// defer t.deinit(alloc); -// t.width_px = 100; -// t.height_px = 100; -// -// var s: ImageStorage = .{}; -// 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, .{ .point = .{ .x = 0, .y = 0 } }); -// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); -// -// s.dirty = false; -// s.delete(alloc, &t, .{ .column = .{ -// .delete = false, -// .x = 60, -// } }); -// try testing.expect(s.dirty); -// try testing.expectEqual(@as(usize, 1), s.placements.count()); -// try testing.expectEqual(@as(usize, 2), s.images.count()); -// -// // verify the placement is what we expect -// try testing.expect(s.placements.get(.{ -// .image_id = 1, -// .placement_id = .{ .tag = .external, .id = 1 }, -// }) != null); -// } -// -// test "storage: delete by row" { -// const testing = std.testing; -// const alloc = testing.allocator; -// var t = try terminal.Terminal.init(alloc, 100, 100); -// defer t.deinit(alloc); -// t.width_px = 100; -// t.height_px = 100; -// -// var s: ImageStorage = .{}; -// 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, .{ .point = .{ .x = 0, .y = 0 } }); -// try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); -// -// s.dirty = false; -// s.delete(alloc, &t, .{ .row = .{ -// .delete = false, -// .y = 60, -// } }); -// try testing.expect(s.dirty); -// try testing.expectEqual(@as(usize, 1), s.placements.count()); -// try testing.expectEqual(@as(usize, 2), s.images.count()); -// -// // verify the placement is what we expect -// try testing.expect(s.placements.get(.{ -// .image_id = 1, -// .placement_id = .{ .tag = .external, .id = 1 }, -// }) != null); -// } +test "storage: delete intersecting cursor plus unused" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + 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); + 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 }) }); + + 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(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }) != null); +} + +test "storage: delete intersecting cursor hits multiple" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + 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); + 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 }) }); + + 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" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + 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); + 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 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .column = .{ + .delete = false, + .x = 60, + } }); + 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(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} + +test "storage: delete by row" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, 100, 100); + 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); + 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 }) }); + + s.dirty = false; + s.delete(alloc, &t, .{ .row = .{ + .delete = false, + .y = 60, + } }); + 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(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }) != null); +} From 7bcb982d738adc1bfd3063aed2cfef326fba57ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 17:09:26 -0800 Subject: [PATCH 184/428] terminal2: use new kitty stack --- src/terminal2/Screen.zig | 2 +- src/terminal2/kitty.zig | 8 +-- src/terminal2/kitty/graphics_storage.zig | 65 +++++++++++++----------- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 2f8b545566..3ba171c457 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -163,7 +163,7 @@ pub fn init( } pub fn deinit(self: *Screen) void { - self.kitty_images.deinit(self.alloc); + self.kitty_images.deinit(self.alloc, self); self.pages.deinit(); } diff --git a/src/terminal2/kitty.zig b/src/terminal2/kitty.zig index e2341a3dc6..497dd4aba1 100644 --- a/src/terminal2/kitty.zig +++ b/src/terminal2/kitty.zig @@ -1,12 +1,8 @@ //! Types and functions related to Kitty protocols. -// TODO: migrate to terminal2 -pub const graphics = @import("../terminal/kitty/graphics.zig"); -pub usingnamespace @import("../terminal/kitty/key.zig"); +pub const graphics = @import("kitty/graphics.zig"); +pub usingnamespace @import("kitty/key.zig"); test { @import("std").testing.refAllDecls(@This()); - - _ = @import("kitty/graphics.zig"); - _ = @import("kitty/key.zig"); } diff --git a/src/terminal2/kitty/graphics_storage.zig b/src/terminal2/kitty/graphics_storage.zig index f6ce3f8089..bde44074b0 100644 --- a/src/terminal2/kitty/graphics_storage.zig +++ b/src/terminal2/kitty/graphics_storage.zig @@ -56,7 +56,7 @@ pub const ImageStorage = struct { pub fn deinit( self: *ImageStorage, alloc: Allocator, - t: *terminal.Terminal, + s: *terminal.Screen, ) void { if (self.loading) |loading| loading.destroy(alloc); @@ -64,7 +64,7 @@ pub const ImageStorage = struct { while (it.next()) |kv| kv.value_ptr.deinit(alloc); self.images.deinit(alloc); - self.clearPlacements(t); + self.clearPlacements(s); self.placements.deinit(alloc); } @@ -175,9 +175,9 @@ pub const ImageStorage = struct { self.dirty = true; } - fn clearPlacements(self: *ImageStorage, t: *terminal.Terminal) void { + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { var it = self.placements.iterator(); - while (it.next()) |entry| entry.value_ptr.deinit(t); + while (it.next()) |entry| entry.value_ptr.deinit(s); self.placements.clearRetainingCapacity(); } @@ -214,14 +214,14 @@ pub const ImageStorage = struct { switch (cmd) { .all => |delete_images| if (delete_images) { // We just reset our entire state. - self.deinit(alloc, t); + self.deinit(alloc, &t.screen); self.* = .{ .dirty = true, .total_limit = self.total_limit, }; } else { // Delete all our placements - self.clearPlacements(t); + self.clearPlacements(&t.screen); self.placements.deinit(alloc); self.placements = .{}; self.dirty = true; @@ -229,16 +229,21 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, - t, + &t.screen, v.image_id, v.placement_id, v.delete, ), .newest => |v| newest: { - if (true) @panic("TODO"); 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| { @@ -293,7 +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); + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -320,7 +325,7 @@ pub const ImageStorage = struct { 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); + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -335,7 +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); + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -354,7 +359,7 @@ pub const ImageStorage = struct { fn deleteById( self: *ImageStorage, alloc: Allocator, - t: *terminal.Terminal, + s: *terminal.Screen, image_id: u32, placement_id: u32, delete_unused: bool, @@ -364,7 +369,7 @@ 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(t); + entry.value_ptr.deinit(s); self.placements.removeByPtr(entry.key_ptr); } } @@ -373,7 +378,7 @@ pub const ImageStorage = struct { .image_id = image_id, .placement_id = .{ .tag = .external, .id = placement_id }, })) |entry| { - entry.value_ptr.deinit(t); + entry.value_ptr.deinit(s); self.placements.removeByPtr(entry.key_ptr); } } @@ -422,7 +427,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t); 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); + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -566,9 +571,9 @@ pub const ImageStorage = struct { pub fn deinit( self: *const Placement, - t: *terminal.Terminal, + s: *terminal.Screen, ) void { - t.screen.pages.untrackPin(self.pin); + s.pages.untrackPin(self.pin); } /// Returns a selection of the entire rectangle this placement @@ -641,7 +646,7 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); @@ -669,7 +674,7 @@ test "storage: delete all placements and images" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -692,7 +697,7 @@ test "storage: delete all placements and images preserves limit" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); @@ -717,7 +722,7 @@ test "storage: delete all placements" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -740,7 +745,7 @@ test "storage: delete all placements by image id" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -763,7 +768,7 @@ test "storage: delete all placements by image id and unused images" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -786,7 +791,7 @@ test "storage: delete placement by specific id" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -816,7 +821,7 @@ test "storage: delete intersecting cursor" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); @@ -848,7 +853,7 @@ test "storage: delete intersecting cursor plus unused" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); @@ -880,7 +885,7 @@ test "storage: delete intersecting cursor hits multiple" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); @@ -906,7 +911,7 @@ test "storage: delete by column" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); @@ -939,7 +944,7 @@ test "storage: delete by row" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t); + 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, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); From e3778ddf92f867f080af8797cb04eeb6a9ef43e0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 17:13:40 -0800 Subject: [PATCH 185/428] terminal2: most imports --- src/terminal2/main.zig | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 14933d3de0..f014e8069a 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -1,12 +1,46 @@ const builtin = @import("builtin"); +const charsets = @import("charsets.zig"); +const stream = @import("stream.zig"); +const ansi = @import("ansi.zig"); +const csi = @import("csi.zig"); +const sgr = @import("sgr.zig"); +//pub const apc = @import("apc.zig"); +//pub const dcs = @import("dcs.zig"); +pub const osc = @import("osc.zig"); +pub const point = @import("point.zig"); +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 point = @import("point.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 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 Terminal = @import("Terminal.zig"); +pub const Parser = @import("Parser.zig"); pub const Screen = @import("Screen.zig"); -pub const Page = page.Page; +pub const Terminal = @import("Terminal.zig"); +pub const Stream = stream.Stream; +pub const Cursor = Screen.Cursor; +pub const CursorStyleReq = ansi.CursorStyle; +pub const DeviceAttributeReq = ansi.DeviceAttributeReq; +pub const Mode = modes.Mode; +pub const ModifyKeyFormat = ansi.ModifyKeyFormat; +pub const ProtectedMode = ansi.ProtectedMode; +pub const StatusLineType = ansi.StatusLineType; +pub const StatusDisplay = ansi.StatusDisplay; +pub const EraseDisplay = csi.EraseDisplay; +pub const EraseLine = csi.EraseLine; +pub const TabClear = csi.TabClear; +pub const Attribute = sgr.Attribute; test { @import("std").testing.refAllDecls(@This()); From c8803dfab23e7492987e4a0ef81b20294b79ac8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 17:14:35 -0800 Subject: [PATCH 186/428] terminal2: more imports --- src/terminal2/main.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index f014e8069a..eccec40661 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -5,8 +5,8 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); -//pub const apc = @import("apc.zig"); -//pub const dcs = @import("dcs.zig"); +pub const apc = @import("apc.zig"); +pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); From 4055f8af76fe7576bd6ee0b3560dc2d4af8da923 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 17:16:05 -0800 Subject: [PATCH 187/428] terminal2: more imports --- src/terminal2/main.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index eccec40661..673a82d1b0 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -1,5 +1,7 @@ const builtin = @import("builtin"); +pub usingnamespace @import("sanitize.zig"); + const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); From 0f5841baca83f5df752dc26b33fee1b7beb36dd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 19:44:58 -0800 Subject: [PATCH 188/428] terminal2: start Selection --- src/terminal/Selection.zig | 2 + src/terminal2/PageList.zig | 24 + src/terminal2/Parser.zig | 794 ++++++++++++ src/terminal2/Screen.zig | 2 +- src/terminal2/Selection.zig | 458 +++++++ src/terminal2/UTF8Decoder.zig | 142 +++ src/terminal2/apc.zig | 137 +++ src/terminal2/dcs.zig | 309 +++++ src/terminal2/device_status.zig | 67 + src/terminal2/main.zig | 1 + src/terminal2/osc.zig | 1274 +++++++++++++++++++ src/terminal2/parse_table.zig | 389 ++++++ src/terminal2/sanitize.zig | 13 + src/terminal2/stream.zig | 2014 +++++++++++++++++++++++++++++++ 14 files changed, 5625 insertions(+), 1 deletion(-) create mode 100644 src/terminal2/Parser.zig create mode 100644 src/terminal2/Selection.zig create mode 100644 src/terminal2/UTF8Decoder.zig create mode 100644 src/terminal2/apc.zig create mode 100644 src/terminal2/dcs.zig create mode 100644 src/terminal2/device_status.zig create mode 100644 src/terminal2/osc.zig create mode 100644 src/terminal2/parse_table.zig create mode 100644 src/terminal2/sanitize.zig create mode 100644 src/terminal2/stream.zig diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index fb83ebbea4..6dc2c77ed4 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -759,6 +759,7 @@ test "Selection: within" { } } +// X test "Selection: order, standard" { const testing = std.testing; { @@ -808,6 +809,7 @@ test "Selection: order, standard" { } } +// X test "Selection: order, rectangle" { const testing = std.testing; // Conventions: diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index c7d8542741..eeff789a97 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1811,6 +1811,30 @@ pub const Pin = struct { return false; } + /// Returns true if self is before other. This is very expensive since + /// it requires traversing the linked list of pages. This should not + /// be called in performance critical paths. + pub fn isBefore(self: Pin, other: Pin) bool { + if (self.page == other.page) { + if (self.y < other.y) return true; + if (self.y > other.y) return false; + return self.x < other.x; + } + + var page = self.page.next; + while (page) |p| : (page = p.next) { + if (p == other.page) return true; + } + + return false; + } + + pub fn eql(self: Pin, other: Pin) bool { + return self.page == other.page and + self.y == other.y and + self.x == other.x; + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { diff --git a/src/terminal2/Parser.zig b/src/terminal2/Parser.zig new file mode 100644 index 0000000000..f160619e27 --- /dev/null +++ b/src/terminal2/Parser.zig @@ -0,0 +1,794 @@ +//! VT-series parser for escape and control sequences. +//! +//! This is implemented directly as the state machine described on +//! vt100.net: https://vt100.net/emu/dec_ansi_parser +const Parser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const testing = std.testing; +const table = @import("parse_table.zig").table; +const osc = @import("osc.zig"); + +const log = std.log.scoped(.parser); + +/// States for the state machine +pub const State = enum { + ground, + escape, + escape_intermediate, + csi_entry, + csi_intermediate, + csi_param, + csi_ignore, + dcs_entry, + dcs_param, + dcs_intermediate, + dcs_passthrough, + dcs_ignore, + osc_string, + sos_pm_apc_string, +}; + +/// Transition action is an action that can be taken during a state +/// transition. This is more of an internal action, not one used by +/// end users, typically. +pub const TransitionAction = enum { + none, + ignore, + print, + execute, + collect, + param, + esc_dispatch, + csi_dispatch, + put, + osc_put, + apc_put, +}; + +/// Action is the action that a caller of the parser is expected to +/// take as a result of some input character. +pub const Action = union(enum) { + pub const Tag = std.meta.FieldEnum(Action); + + /// Draw character to the screen. This is a unicode codepoint. + print: u21, + + /// Execute the C0 or C1 function. + execute: u8, + + /// Execute the CSI command. Note that pointers within this + /// structure are only valid until the next call to "next". + csi_dispatch: CSI, + + /// Execute the ESC command. + esc_dispatch: ESC, + + /// Execute the OSC command. + osc_dispatch: osc.Command, + + /// DCS-related events. + dcs_hook: DCS, + dcs_put: u8, + dcs_unhook: void, + + /// APC data + apc_start: void, + apc_put: u8, + apc_end: void, + + pub const CSI = struct { + intermediates: []u8, + params: []u16, + final: u8, + sep: Sep, + + /// The separator used for CSI params. + pub const Sep = enum { semicolon, colon }; + + // Implement formatter for logging + pub fn format( + self: CSI, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + try std.fmt.format(writer, "ESC [ {s} {any} {c}", .{ + self.intermediates, + self.params, + self.final, + }); + } + }; + + pub const ESC = struct { + intermediates: []u8, + final: u8, + + // Implement formatter for logging + pub fn format( + self: ESC, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + try std.fmt.format(writer, "ESC {s} {c}", .{ + self.intermediates, + self.final, + }); + } + }; + + pub const DCS = struct { + intermediates: []const u8 = "", + params: []const u16 = &.{}, + final: u8, + }; + + // Implement formatter for logging. This is mostly copied from the + // std.fmt implementation, but we modify it slightly so that we can + // print out custom formats for some of our primitives. + pub fn format( + self: Action, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + const T = Action; + const info = @typeInfo(T).Union; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + // If this is the active field... + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (@TypeOf(value)) { + // Unicode + u21 => try std.fmt.format(writer, "'{u}' (U+{X})", .{ value, value }), + + // Byte + u8 => try std.fmt.format(writer, "0x{x}", .{value}), + + // Note: we don't do ASCII (u8) because there are a lot + // of invisible characters we don't want to handle right + // now. + + // All others do the default behavior + else => try std.fmt.formatType( + @field(self, u_field.name), + "any", + opts, + writer, + 3, + ), + } + } + } + + try writer.writeAll(" }"); + } else { + try format(writer, "@{x}", .{@intFromPtr(&self)}); + } + } +}; + +/// Keeps track of the parameter sep used for CSI params. We allow colons +/// to be used ONLY by the 'm' CSI action. +pub const ParamSepState = enum(u8) { + none = 0, + semicolon = ';', + colon = ':', + mixed = 1, +}; + +/// Maximum number of intermediate characters during parsing. This is +/// 4 because we also use the intermediates array for UTF8 decoding which +/// can be at most 4 bytes. +const MAX_INTERMEDIATE = 4; +const MAX_PARAMS = 16; + +/// Current state of the state machine +state: State = .ground, + +/// Intermediate tracking. +intermediates: [MAX_INTERMEDIATE]u8 = undefined, +intermediates_idx: u8 = 0, + +/// Param tracking, building +params: [MAX_PARAMS]u16 = undefined, +params_idx: u8 = 0, +params_sep: ParamSepState = .none, +param_acc: u16 = 0, +param_acc_idx: u8 = 0, + +/// Parser for OSC sequences +osc_parser: osc.Parser = .{}, + +pub fn init() Parser { + return .{}; +} + +pub fn deinit(self: *Parser) void { + self.osc_parser.deinit(); +} + +/// Next consumes the next character c and returns the actions to execute. +/// Up to 3 actions may need to be executed -- in order -- representing +/// the state exit, transition, and entry actions. +pub fn next(self: *Parser, c: u8) [3]?Action { + const effect = table[c][@intFromEnum(self.state)]; + + // log.info("next: {x}", .{c}); + + const next_state = effect.state; + const action = effect.action; + + // After generating the actions, we set our next state. + defer self.state = next_state; + + // When going from one state to another, the actions take place in this order: + // + // 1. exit action from old state + // 2. transition action + // 3. entry action to new state + return [3]?Action{ + // Exit depends on current state + if (self.state == next_state) null else switch (self.state) { + .osc_string => if (self.osc_parser.end(c)) |cmd| + Action{ .osc_dispatch = cmd } + else + null, + .dcs_passthrough => Action{ .dcs_unhook = {} }, + .sos_pm_apc_string => Action{ .apc_end = {} }, + else => null, + }, + + self.doAction(action, c), + + // Entry depends on new state + if (self.state == next_state) null else switch (next_state) { + .escape, .dcs_entry, .csi_entry => clear: { + self.clear(); + break :clear null; + }, + .osc_string => osc_string: { + self.osc_parser.reset(); + break :osc_string null; + }, + .dcs_passthrough => Action{ + .dcs_hook = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .params = self.params[0..self.params_idx], + .final = c, + }, + }, + .sos_pm_apc_string => Action{ .apc_start = {} }, + else => null, + }, + }; +} + +pub fn collect(self: *Parser, c: u8) void { + if (self.intermediates_idx >= MAX_INTERMEDIATE) { + log.warn("invalid intermediates count", .{}); + return; + } + + self.intermediates[self.intermediates_idx] = c; + self.intermediates_idx += 1; +} + +fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { + return switch (action) { + .none, .ignore => null, + .print => Action{ .print = c }, + .execute => Action{ .execute = c }, + .collect => collect: { + self.collect(c); + break :collect null; + }, + .param => param: { + // Semicolon separates parameters. If we encounter a semicolon + // we need to store and move on to the next parameter. + if (c == ';' or c == ':') { + // Ignore too many parameters + if (self.params_idx >= MAX_PARAMS) break :param null; + + // If this is our first time seeing a parameter, we track + // the separator used so that we can't mix separators later. + if (self.params_idx == 0) self.params_sep = @enumFromInt(c); + if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed; + + // Set param final value + self.params[self.params_idx] = self.param_acc; + self.params_idx += 1; + + // Reset current param value to 0 + self.param_acc = 0; + self.param_acc_idx = 0; + break :param null; + } + + // A numeric value. Add it to our accumulator. + if (self.param_acc_idx > 0) { + self.param_acc *|= 10; + } + self.param_acc +|= c - '0'; + + // Increment our accumulator index. If we overflow then + // we're out of bounds and we exit immediately. + self.param_acc_idx, const overflow = @addWithOverflow(self.param_acc_idx, 1); + if (overflow > 0) break :param null; + + // The client is expected to perform no action. + break :param null; + }, + .osc_put => osc_put: { + self.osc_parser.next(c); + break :osc_put null; + }, + .csi_dispatch => csi_dispatch: { + // Ignore too many parameters + if (self.params_idx >= MAX_PARAMS) break :csi_dispatch null; + + // Finalize parameters if we have one + if (self.param_acc_idx > 0) { + self.params[self.params_idx] = self.param_acc; + self.params_idx += 1; + } + + const result: Action = .{ + .csi_dispatch = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .params = self.params[0..self.params_idx], + .final = c, + .sep = switch (self.params_sep) { + .none, .semicolon => .semicolon, + .colon => .colon, + + // There is nothing that treats mixed separators specially + // afaik so we just treat it as a semicolon. + .mixed => .semicolon, + }, + }, + }; + + // We only allow colon or mixed separators for the 'm' command. + switch (self.params_sep) { + .none => {}, + .semicolon => {}, + .colon, .mixed => if (c != 'm') { + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {}", + .{result}, + ); + break :csi_dispatch null; + }, + } + + break :csi_dispatch result; + }, + .esc_dispatch => Action{ + .esc_dispatch = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .final = c, + }, + }, + .put => Action{ .dcs_put = c }, + .apc_put => Action{ .apc_put = c }, + }; +} + +pub fn clear(self: *Parser) void { + self.intermediates_idx = 0; + self.params_idx = 0; + self.params_sep = .none; + self.param_acc = 0; + self.param_acc_idx = 0; +} + +test { + var p = init(); + _ = p.next(0x9E); + try testing.expect(p.state == .sos_pm_apc_string); + _ = p.next(0x9C); + try testing.expect(p.state == .ground); + + { + const a = p.next('a'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .print); + try testing.expect(a[2] == null); + } + + { + const a = p.next(0x19); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .execute); + try testing.expect(a[2] == null); + } +} + +test "esc: ESC ( B" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('('); + + { + const a = p.next('B'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .esc_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.esc_dispatch; + try testing.expect(d.final == 'B'); + try testing.expect(d.intermediates.len == 1); + try testing.expect(d.intermediates[0] == '('); + } +} + +test "csi: ESC [ H" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(0x5B); + + { + const a = p.next(0x48); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 0x48); + try testing.expect(d.params.len == 0); + } +} + +test "csi: ESC [ 1 ; 4 H" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(0x5B); + _ = p.next(0x31); // 1 + _ = p.next(0x3B); // ; + _ = p.next(0x34); // 4 + + { + const a = p.next(0x48); // H + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'H'); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 1), d.params[0]); + try testing.expectEqual(@as(u16, 4), d.params[1]); + } +} + +test "csi: SGR ESC [ 38 : 2 m" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + _ = p.next('3'); + _ = p.next('8'); + _ = p.next(':'); + _ = p.next('2'); + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 38), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + } +} + +test "csi: SGR colon followed by semicolon" { + var p = init(); + _ = p.next(0x1B); + for ("[48:2") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } + + _ = p.next(0x1B); + _ = p.next('['); + { + const a = p.next('H'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } +} + +test "csi: SGR mixed colon and semicolon" { + var p = init(); + _ = p.next(0x1B); + for ("[38:5:1;48:5:0") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } +} + +test "csi: SGR ESC [ 48 : 2 m" { + var p = init(); + _ = p.next(0x1B); + for ("[48:2:240:143:104") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 5); + try testing.expectEqual(@as(u16, 48), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expectEqual(@as(u16, 240), d.params[2]); + try testing.expectEqual(@as(u16, 143), d.params[3]); + try testing.expectEqual(@as(u16, 104), d.params[4]); + } +} + +test "csi: SGR ESC [4:3m colon" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + _ = p.next('4'); + _ = p.next(':'); + _ = p.next('3'); + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 4), d.params[0]); + try testing.expectEqual(@as(u16, 3), d.params[1]); + } +} + +test "csi: SGR with many blank and colon" { + var p = init(); + _ = p.next(0x1B); + for ("[58:2::240:143:104") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 6); + try testing.expectEqual(@as(u16, 58), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expectEqual(@as(u16, 0), d.params[2]); + try testing.expectEqual(@as(u16, 240), d.params[3]); + try testing.expectEqual(@as(u16, 143), d.params[4]); + try testing.expectEqual(@as(u16, 104), d.params[5]); + } +} + +test "csi: colon for non-m final" { + var p = init(); + _ = p.next(0x1B); + for ("[38:2h") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + try testing.expect(p.state == .ground); +} + +test "csi: request mode decrqm" { + var p = init(); + _ = p.next(0x1B); + for ("[?2026$") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('p'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'p'); + try testing.expectEqual(@as(usize, 2), d.intermediates.len); + try testing.expectEqual(@as(usize, 1), d.params.len); + try testing.expectEqual(@as(u16, '?'), d.intermediates[0]); + try testing.expectEqual(@as(u16, '$'), d.intermediates[1]); + try testing.expectEqual(@as(u16, 2026), d.params[0]); + } +} + +test "csi: change cursor" { + var p = init(); + _ = p.next(0x1B); + for ("[3 ") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('q'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'q'); + try testing.expectEqual(@as(usize, 1), d.intermediates.len); + try testing.expectEqual(@as(usize, 1), d.params.len); + try testing.expectEqual(@as(u16, ' '), d.intermediates[0]); + try testing.expectEqual(@as(u16, 3), d.params[0]); + } +} + +test "osc: change window title" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('0'); + _ = p.next(';'); + _ = p.next('a'); + _ = p.next('b'); + _ = p.next('c'); + + { + const a = p.next(0x07); // BEL + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); + } +} + +test "osc: change window title (end in esc)" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('0'); + _ = p.next(';'); + _ = p.next('a'); + _ = p.next('b'); + _ = p.next('c'); + + { + const a = p.next(0x1B); + _ = p.next('\\'); + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); + } +} + +// https://github.com/darrenstarr/VtNetCore/pull/14 +// Saw this on HN, decided to add a test case because why not. +test "osc: 112 incomplete sequence" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('1'); + _ = p.next('1'); + _ = p.next('2'); + + { + const a = p.next(0x07); + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .reset_color); + try testing.expectEqual(cmd.reset_color.kind, .cursor); + } +} + +test "csi: too many params" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + for (0..100) |_| { + _ = p.next('1'); + _ = p.next(';'); + } + _ = p.next('1'); + + { + const a = p.next('C'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } +} diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 3ba171c457..17fd1301a1 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -925,7 +925,7 @@ pub fn dumpStringAlloc( /// 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. -fn testWriteString(self: *Screen, text: []const u8) !void { +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| { diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig new file mode 100644 index 0000000000..4401776041 --- /dev/null +++ b/src/terminal2/Selection.zig @@ -0,0 +1,458 @@ +//! 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 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! + +/// 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. +/// +/// These are always tracked pins so that they automatically update as +/// the screen they're attached to gets scrolled, erased, etc. +start: *Pin, +end: *Pin, + +/// 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, + +/// 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( + s: *Screen, + start: Pin, + end: Pin, + rect: bool, +) !Selection { + // Track our pins + const tracked_start = try s.pages.trackPin(start); + errdefer s.pages.untrackPin(tracked_start); + const tracked_end = try s.pages.trackPin(end); + errdefer s.pages.untrackPin(tracked_end); + + return .{ + .start = tracked_start, + .end = tracked_end, + .rectangle = rect, + }; +} + +pub fn deinit( + self: Selection, + s: *Screen, +) void { + s.pages.untrackPin(self.start); + s.pages.untrackPin(self.end); +} + +/// 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; +} + +/// 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 { + _ = self; + _ = s; + + //const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; + + // 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; + //}, + + else => @panic("TODO"), + } +} + +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 = try Selection.init( + // &s, + // 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. + // { + // 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); + // } +} + +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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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); + } +} diff --git a/src/terminal2/UTF8Decoder.zig b/src/terminal2/UTF8Decoder.zig new file mode 100644 index 0000000000..6bb0d98159 --- /dev/null +++ b/src/terminal2/UTF8Decoder.zig @@ -0,0 +1,142 @@ +//! DFA-based non-allocating error-replacing UTF-8 decoder. +//! +//! This implementation is based largely on the excellent work of +//! Bjoern Hoehrmann, with slight modifications to support error- +//! replacement. +//! +//! For details on Bjoern's DFA-based UTF-8 decoder, see +//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed) +const UTF8Decoder = @This(); + +const std = @import("std"); +const testing = std.testing; + +const log = std.log.scoped(.utf8decoder); + +// zig fmt: off +const char_classes = [_]u4{ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, + 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, +}; + +const transitions = [_]u8 { + 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, + 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, + 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, + 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, + 12,36,12,12,12,12,12,12,12,12,12,12, +}; +// zig fmt: on + +// DFA states +const ACCEPT_STATE = 0; +const REJECT_STATE = 12; + +// This is where we accumulate our current codepoint. +accumulator: u21 = 0, +// The internal state of the DFA. +state: u8 = ACCEPT_STATE, + +/// Takes the next byte in the utf-8 sequence and emits a tuple of +/// - The codepoint that was generated, if there is one. +/// - A boolean that indicates whether the provided byte was consumed. +/// +/// The only case where the byte is not consumed is if an ill-formed +/// sequence is reached, in which case a replacement character will be +/// emitted and the byte will not be consumed. +/// +/// If the byte is not consumed, the caller is responsible for calling +/// again with the same byte before continuing. +pub inline fn next(self: *UTF8Decoder, byte: u8) struct { ?u21, bool } { + const char_class = char_classes[byte]; + + const initial_state = self.state; + + if (self.state != ACCEPT_STATE) { + self.accumulator <<= 6; + self.accumulator |= (byte & 0x3F); + } else { + self.accumulator = (@as(u21, 0xFF) >> char_class) & (byte); + } + + self.state = transitions[self.state + char_class]; + + if (self.state == ACCEPT_STATE) { + defer self.accumulator = 0; + + // Emit the fully decoded codepoint. + return .{ self.accumulator, true }; + } else if (self.state == REJECT_STATE) { + self.accumulator = 0; + self.state = ACCEPT_STATE; + // Emit a replacement character. If we rejected the first byte + // in a sequence, then it was consumed, otherwise it was not. + return .{ 0xFFFD, initial_state == ACCEPT_STATE }; + } else { + // Emit nothing, we're in the middle of a sequence. + return .{ null, true }; + } +} + +test "ASCII" { + var d: UTF8Decoder = .{}; + var out: [13]u8 = undefined; + for ("Hello, World!", 0..) |byte, i| { + const res = d.next(byte); + try testing.expect(res[1]); + if (res[0]) |codepoint| { + out[i] = @intCast(codepoint); + } + } + + try testing.expect(std.mem.eql(u8, &out, "Hello, World!")); +} + +test "Well formed utf-8" { + var d: UTF8Decoder = .{}; + var out: [4]u21 = undefined; + var i: usize = 0; + // 4 bytes, 3 bytes, 2 bytes, 1 byte + for ("😄✤ÁA") |byte| { + var consumed = false; + while (!consumed) { + const res = d.next(byte); + consumed = res[1]; + // There are no errors in this sequence, so + // every byte should be consumed first try. + try testing.expect(consumed == true); + if (res[0]) |codepoint| { + out[i] = codepoint; + i += 1; + } + } + } + + try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0x1F604, 0x2724, 0xC1, 0x41 })); +} + +test "Partially invalid utf-8" { + var d: UTF8Decoder = .{}; + var out: [5]u21 = undefined; + var i: usize = 0; + // Illegally terminated sequence, valid sequence, illegal surrogate pair. + for ("\xF0\x9F😄\xED\xA0\x80") |byte| { + var consumed = false; + while (!consumed) { + const res = d.next(byte); + consumed = res[1]; + if (res[0]) |codepoint| { + out[i] = codepoint; + i += 1; + } + } + } + + try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD })); +} diff --git a/src/terminal2/apc.zig b/src/terminal2/apc.zig new file mode 100644 index 0000000000..6a6b8cc360 --- /dev/null +++ b/src/terminal2/apc.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const kitty_gfx = @import("kitty/graphics.zig"); + +const log = std.log.scoped(.terminal_apc); + +/// APC command handler. This should be hooked into a terminal.Stream handler. +/// The start/feed/end functions are meant to be called from the terminal.Stream +/// apcStart, apcPut, and apcEnd functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + pub fn deinit(self: *Handler) void { + self.state.deinit(); + } + + pub fn start(self: *Handler) void { + self.state.deinit(); + self.state = .{ .identify = {} }; + } + + pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { + switch (self.state) { + .inactive => unreachable, + + // We're ignoring this APC command, likely because we don't + // recognize it so there is no need to store the data in memory. + .ignore => return, + + // We identify the APC command by the first byte. + .identify => { + switch (byte) { + // Kitty graphics protocol + 'G' => self.state = .{ .kitty = kitty_gfx.CommandParser.init(alloc) }, + + // Unknown + else => self.state = .{ .ignore = {} }, + } + }, + + .kitty => |*p| p.feed(byte) catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + self.state = .{ .ignore = {} }; + }, + } + } + + pub fn end(self: *Handler) ?Command { + defer { + self.state.deinit(); + self.state = .{ .inactive = {} }; + } + + return switch (self.state) { + .inactive => unreachable, + .ignore, .identify => null, + .kitty => |*p| kitty: { + const command = p.complete() catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + break :kitty null; + }; + + break :kitty .{ .kitty = command }; + }, + }; + } +}; + +pub const State = union(enum) { + /// We're not in the middle of an APC command yet. + inactive: void, + + /// We got an unrecognized APC sequence or the APC sequence we + /// recognized became invalid. We're just dropping bytes. + ignore: void, + + /// We're waiting to identify the APC sequence. This is done by + /// inspecting the first byte of the sequence. + identify: void, + + /// Kitty graphics protocol + kitty: kitty_gfx.CommandParser, + + pub fn deinit(self: *State) void { + switch (self.*) { + .inactive, .ignore, .identify => {}, + .kitty => |*v| v.deinit(), + } + } +}; + +/// Possible APC commands. +pub const Command = union(enum) { + kitty: kitty_gfx.Command, + + pub fn deinit(self: *Command, alloc: Allocator) void { + switch (self.*) { + .kitty => |*v| v.deinit(alloc), + } + } +}; + +test "unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Xabcdef1234") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "garbage Kitty command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Gabcdef1234") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "valid Kitty command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + const input = "Gf=24,s=10,v=20,hello=world"; + for (input) |c| h.feed(alloc, c); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); +} diff --git a/src/terminal2/dcs.zig b/src/terminal2/dcs.zig new file mode 100644 index 0000000000..cde00d2188 --- /dev/null +++ b/src/terminal2/dcs.zig @@ -0,0 +1,309 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminal = @import("main.zig"); +const DCS = terminal.DCS; + +const log = std.log.scoped(.terminal_dcs); + +/// DCS command handler. This should be hooked into a terminal.Stream handler. +/// The hook/put/unhook functions are meant to be called from the +/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + /// Maximum bytes any DCS command can take. This is to prevent + /// malicious input from causing us to allocate too much memory. + /// This is arbitrarily set to 1MB today, increase if needed. + max_bytes: usize = 1024 * 1024, + + pub fn deinit(self: *Handler) void { + self.discard(); + } + + pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void { + assert(self.state == .inactive); + self.state = if (tryHook(alloc, dcs)) |state_| state: { + if (state_) |state| break :state state else { + log.info("unknown DCS hook: {}", .{dcs}); + break :state .{ .ignore = {} }; + } + } else |err| state: { + log.info( + "error initializing DCS hook, will ignore hook err={}", + .{err}, + ); + break :state .{ .ignore = {} }; + }; + } + + fn tryHook(alloc: Allocator, dcs: DCS) !?State { + return switch (dcs.intermediates.len) { + 1 => switch (dcs.intermediates[0]) { + '+' => switch (dcs.final) { + // XTGETTCAP + // https://github.com/mitchellh/ghostty/issues/517 + 'q' => .{ + .xtgettcap = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice + ), + }, + + else => null, + }, + + '$' => switch (dcs.final) { + // DECRQSS + 'q' => .{ + .decrqss = .{}, + }, + + else => null, + }, + + else => null, + }, + + else => null, + }; + } + + pub fn put(self: *Handler, byte: u8) void { + self.tryPut(byte) catch |err| { + // On error we just discard our state and ignore the rest + log.info("error putting byte into DCS handler err={}", .{err}); + self.discard(); + self.state = .{ .ignore = {} }; + }; + } + + fn tryPut(self: *Handler, byte: u8) !void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| { + if (list.items.len >= self.max_bytes) { + return error.OutOfMemory; + } + + try list.append(byte); + }, + + .decrqss => |*buffer| { + if (buffer.len >= buffer.data.len) { + return error.OutOfMemory; + } + + buffer.data[buffer.len] = byte; + buffer.len += 1; + }, + } + } + + pub fn unhook(self: *Handler) ?Command { + defer self.state = .{ .inactive = {} }; + return switch (self.state) { + .inactive, + .ignore, + => null, + + .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, + + .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { + 0 => .none, + 1 => switch (buffer.data[0]) { + 'm' => .sgr, + 'r' => .decstbm, + 's' => .decslrm, + else => .none, + }, + 2 => switch (buffer.data[0]) { + ' ' => switch (buffer.data[1]) { + 'q' => .decscusr, + else => .none, + }, + else => .none, + }, + else => unreachable, + } }, + }; + } + + fn discard(self: *Handler) void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| list.deinit(), + + .decrqss => {}, + } + + self.state = .{ .inactive = {} }; + } +}; + +pub const Command = union(enum) { + /// XTGETTCAP + xtgettcap: XTGETTCAP, + + /// DECRQSS + decrqss: DECRQSS, + + pub fn deinit(self: Command) void { + switch (self) { + .xtgettcap => |*v| { + v.data.deinit(); + }, + .decrqss => {}, + } + } + + pub const XTGETTCAP = struct { + data: std.ArrayList(u8), + i: usize = 0, + + /// Returns the next terminfo key being requested and null + /// when there are no more keys. The returned value is NOT hex-decoded + /// because we expect to use a comptime lookup table. + pub fn next(self: *XTGETTCAP) ?[]const u8 { + if (self.i >= self.data.items.len) return null; + + var rem = self.data.items[self.i..]; + const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len; + + // Note that if we're at the end, idx + 1 is len + 1 so we're over + // the end but that's okay because our check above is >= so we'll + // never read. + self.i += idx + 1; + + return rem[0..idx]; + } + }; + + /// Supported DECRQSS settings + pub const DECRQSS = enum { + none, + sgr, + decscusr, + decstbm, + decslrm, + }; +}; + +const State = union(enum) { + /// We're not in a DCS state at the moment. + inactive: void, + + /// We're hooked, but its an unknown DCS command or one that went + /// invalid due to some bad input, so we're ignoring the rest. + ignore: void, + + /// XTGETTCAP + xtgettcap: std.ArrayList(u8), + + /// DECRQSS + decrqss: struct { + data: [2]u8 = undefined, + len: u2 = 0, + }, +}; + +test "unknown DCS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .final = 'A' }); + try testing.expect(h.state == .ignore); + try testing.expect(h.unhook() == null); + try testing.expect(h.state == .inactive); +} + +test "XTGETTCAP command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command multiple keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command invalid data" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("who;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("who", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "DECRQSS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('m'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .sgr); +} + +test "DECRQSS invalid command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('z'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .none); + + h.discard(); + + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('"'); + h.put(' '); + h.put('q'); + try testing.expect(h.unhook() == null); +} diff --git a/src/terminal2/device_status.zig b/src/terminal2/device_status.zig new file mode 100644 index 0000000000..78147ddd40 --- /dev/null +++ b/src/terminal2/device_status.zig @@ -0,0 +1,67 @@ +const std = @import("std"); + +/// An enum(u16) of the available device status requests. +pub const Request = dsr_enum: { + const EnumField = std.builtin.Type.EnumField; + var fields: [entries.len]EnumField = undefined; + for (entries, 0..) |entry, i| { + fields[i] = .{ + .name = entry.name, + .value = @as(Tag.Backing, @bitCast(Tag{ + .value = entry.value, + .question = entry.question, + })), + }; + } + + break :dsr_enum @Type(.{ .Enum = .{ + .tag_type = Tag.Backing, + .fields = &fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +/// The tag type for our enum is a u16 but we use a packed struct +/// in order to pack the question bit into the tag. The "u16" size is +/// chosen somewhat arbitrarily to match the largest expected size +/// we see as a multiple of 8 bits. +pub const Tag = packed struct(u16) { + pub const Backing = @typeInfo(@This()).Struct.backing_integer.?; + value: u15, + question: bool = false, + + test "order" { + const t: Tag = .{ .value = 1 }; + const int: Backing = @bitCast(t); + try std.testing.expectEqual(@as(Backing, 1), int); + } +}; + +pub fn reqFromInt(v: u16, question: bool) ?Request { + inline for (entries) |entry| { + if (entry.value == v and entry.question == question) { + const tag: Tag = .{ .question = question, .value = entry.value }; + const int: Tag.Backing = @bitCast(tag); + return @enumFromInt(int); + } + } + + return null; +} + +/// A single entry of a possible device status request we support. The +/// "question" field determines if it is valid with or without the "?" +/// prefix. +const Entry = struct { + name: [:0]const u8, + value: comptime_int, + question: bool = false, // "?" request +}; + +/// The full list of device status request entries. +const entries: []const Entry = &.{ + .{ .name = "operating_status", .value = 5 }, + .{ .name = "cursor_position", .value = 6 }, + .{ .name = "color_scheme", .value = 996, .question = true }, +}; diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 673a82d1b0..2d813c02ba 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -52,4 +52,5 @@ test { _ = @import("hash_map.zig"); _ = @import("size.zig"); _ = @import("style.zig"); + _ = @import("Selection.zig"); } diff --git a/src/terminal2/osc.zig b/src/terminal2/osc.zig new file mode 100644 index 0000000000..a220ea031a --- /dev/null +++ b/src/terminal2/osc.zig @@ -0,0 +1,1274 @@ +//! OSC (Operating System Command) related functions and types. OSC is +//! another set of control sequences for terminal programs that start with +//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings +//! and other irregular formatting so a dedicated parser is created to handle it. +const osc = @This(); + +const std = @import("std"); +const mem = std.mem; +const assert = std.debug.assert; +const Allocator = mem.Allocator; + +const log = std.log.scoped(.osc); + +pub const Command = union(enum) { + /// Set the window title of the terminal + /// + /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 + /// with each code unit further encoded with two hex digets). + /// + /// If title mode 2 is set or the terminal is setup for unconditional + /// utf-8 titles text is interpreted as utf-8. Else text is interpreted + /// as latin1. + change_window_title: []const u8, + + /// Set the icon of the terminal window. The name of the icon is not + /// well defined, so this is currently ignored by Ghostty at the time + /// of writing this. We just parse it so that we don't get parse errors + /// in the log. + change_window_icon: []const u8, + + /// First do a fresh-line. Then start a new command, and enter prompt mode: + /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a + /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed + /// not all shells will send the prompt end code. + prompt_start: struct { + aid: ?[]const u8 = null, + kind: enum { primary, right, continuation } = .primary, + redraw: bool = true, + }, + + /// End of prompt and start of user input, terminated by a OSC "133;C" + /// or another prompt (OSC "133;P"). + prompt_end: void, + + /// The OSC "133;C" command can be used to explicitly end + /// the input area and begin the output area. However, some applications + /// don't provide a convenient way to emit that command. + /// That is why we also specify an implicit way to end the input area + /// at the end of the line. In the case of multiple input lines: If the + /// cursor is on a fresh (empty) line and we see either OSC "133;P" or + /// OSC "133;I" then this is the start of a continuation input line. + /// If we see anything else, it is the start of the output area (or end + /// of command). + end_of_input: void, + + /// End of current command. + /// + /// The exit-code need not be specified if if there are no options, + /// or if the command was cancelled (no OSC "133;C"), such as by typing + /// an interrupt/cancel character (typically ctrl-C) during line-editing. + /// Otherwise, it must be an integer code, where 0 means the command + /// succeeded, and other values indicate failure. In additing to the + /// exit-code there may be an err= option, which non-legacy terminals + /// should give precedence to. The err=_value_ option is more general: + /// an empty string is success, and any non-empty value (which need not + /// be an integer) is an error code. So to indicate success both ways you + /// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter. + end_of_command: struct { + exit_code: ?u8 = null, + // TODO: err option + }, + + /// Set or get clipboard contents. If data is null, then the current + /// clipboard contents are sent to the pty. If data is set, this + /// contents is set on the clipboard. + clipboard_contents: struct { + kind: u8, + data: []const u8, + }, + + /// OSC 7. Reports the current working directory of the shell. This is + /// a moderately flawed escape sequence but one that many major terminals + /// support so we also support it. To understand the flaws, read through + /// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20 + report_pwd: struct { + /// The reported pwd value. This is not checked for validity. It should + /// be a file URL but it is up to the caller to utilize this value. + value: []const u8, + }, + + /// OSC 22. Set the mouse shape. There doesn't seem to be a standard + /// naming scheme for cursors but it looks like terminals such as Foot + /// are moving towards using the W3C CSS cursor names. For OSC parsing, + /// we just parse whatever string is given. + mouse_shape: struct { + value: []const u8, + }, + + /// OSC 4, OSC 10, and OSC 11 color report. + report_color: struct { + /// OSC 4 requests a palette color, OSC 10 requests the foreground + /// color, OSC 11 the background color. + kind: ColorKind, + + /// We must reply with the same string terminator (ST) as used in the + /// request. + terminator: Terminator = .st, + }, + + /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) + set_color: struct { + /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 + /// the background color. + kind: ColorKind, + + /// The color spec as a string + value: []const u8, + }, + + /// Reset a palette color (OSC 104) or the foreground (OSC 110), background + /// (OSC 111), or cursor (OSC 112) color. + reset_color: struct { + kind: ColorKind, + + /// OSC 104 can have parameters indicating which palette colors to + /// reset. + value: []const u8, + }, + + /// Show a desktop notification (OSC 9 or OSC 777) + show_desktop_notification: struct { + title: []const u8, + body: []const u8, + }, + + pub const ColorKind = union(enum) { + palette: u8, + foreground, + background, + cursor, + + pub fn code(self: ColorKind) []const u8 { + return switch (self) { + .palette => "4", + .foreground => "10", + .background => "11", + .cursor => "12", + }; + } + }; +}; + +/// The terminator used to end an OSC command. For OSC commands that demand +/// a response, we try to match the terminator used in the request since that +/// is most likely to be accepted by the calling program. +pub const Terminator = enum { + /// The preferred string terminator is ESC followed by \ + st, + + /// Some applications and terminals use BELL (0x07) as the string terminator. + bel, + + /// Initialize the terminator based on the last byte seen. If the + /// last byte is a BEL then we use BEL, otherwise we just assume ST. + pub fn init(ch: ?u8) Terminator { + return switch (ch orelse return .st) { + 0x07 => .bel, + else => .st, + }; + } + + /// The terminator as a string. This is static memory so it doesn't + /// need to be freed. + pub fn string(self: Terminator) []const u8 { + return switch (self) { + .st => "\x1b\\", + .bel => "\x07", + }; + } +}; + +pub const Parser = struct { + /// Optional allocator used to accept data longer than MAX_BUF. + /// This only applies to some commands (e.g. OSC 52) that can + /// reasonably exceed MAX_BUF. + alloc: ?Allocator = null, + + /// Current state of the parser. + state: State = .empty, + + /// Current command of the parser, this accumulates. + command: Command = undefined, + + /// Buffer that stores the input we see for a single OSC command. + /// Slices in Command are offsets into this buffer. + buf: [MAX_BUF]u8 = undefined, + buf_start: usize = 0, + buf_idx: usize = 0, + buf_dynamic: ?*std.ArrayListUnmanaged(u8) = null, + + /// True when a command is complete/valid to return. + complete: bool = false, + + /// Temporary state that is dependent on the current state. + temp_state: union { + /// Current string parameter being populated + str: *[]const u8, + + /// Current numeric parameter being populated + num: u16, + + /// Temporary state for key/value pairs + key: []const u8, + } = undefined, + + // Maximum length of a single OSC command. This is the full OSC command + // sequence length (excluding ESC ]). This is arbitrary, I couldn't find + // any definitive resource on how long this should be. + const MAX_BUF = 2048; + + pub const State = enum { + empty, + invalid, + + // Command prefixes. We could just accumulate and compare (mem.eql) + // but the state space is small enough that we just build it up this way. + @"0", + @"1", + @"10", + @"11", + @"12", + @"13", + @"133", + @"2", + @"22", + @"4", + @"5", + @"52", + @"7", + @"77", + @"777", + @"9", + + // OSC 10 is used to query or set the current foreground color. + query_fg_color, + + // OSC 11 is used to query or set the current background color. + query_bg_color, + + // OSC 12 is used to query or set the current cursor color. + query_cursor_color, + + // We're in a semantic prompt OSC command but we aren't sure + // what the command is yet, i.e. `133;` + semantic_prompt, + semantic_option_start, + semantic_option_key, + semantic_option_value, + semantic_exit_code_start, + semantic_exit_code, + + // Get/set clipboard states + clipboard_kind, + clipboard_kind_end, + + // Get/set color palette index + color_palette_index, + color_palette_index_end, + + // Reset color palette index + reset_color_palette_index, + + // rxvt extension. Only used for OSC 777 and only the value "notify" is + // supported + rxvt_extension, + + // Title of a desktop notification + notification_title, + + // Expect a string parameter. param_str must be set as well as + // buf_start. + string, + + // A string that can grow beyond MAX_BUF. This uses the allocator. + // If the parser has no allocator then it is treated as if the + // buffer is full. + allocable_string, + }; + + /// This must be called to clean up any allocated memory. + pub fn deinit(self: *Parser) void { + self.reset(); + } + + /// Reset the parser start. + pub fn reset(self: *Parser) void { + self.state = .empty; + self.buf_start = 0; + self.buf_idx = 0; + self.complete = false; + if (self.buf_dynamic) |ptr| { + const alloc = self.alloc.?; + ptr.deinit(alloc); + alloc.destroy(ptr); + self.buf_dynamic = null; + } + } + + /// Consume the next character c and advance the parser state. + pub fn next(self: *Parser, c: u8) void { + // If our buffer is full then we're invalid. + if (self.buf_idx >= self.buf.len) { + self.state = .invalid; + return; + } + + // We store everything in the buffer so we can do a better job + // logging if we get to an invalid command. + self.buf[self.buf_idx] = c; + self.buf_idx += 1; + + // log.warn("state = {} c = {x}", .{ self.state, c }); + + switch (self.state) { + // If we get something during the invalid state, we've + // ruined our entry. + .invalid => self.complete = false, + + .empty => switch (c) { + '0' => self.state = .@"0", + '1' => self.state = .@"1", + '2' => self.state = .@"2", + '4' => self.state = .@"4", + '5' => self.state = .@"5", + '7' => self.state = .@"7", + '9' => self.state = .@"9", + else => self.state = .invalid, + }, + + .@"0" => switch (c) { + ';' => { + self.command = .{ .change_window_title = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_title }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"1" => switch (c) { + ';' => { + self.command = .{ .change_window_icon = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_icon }; + self.buf_start = self.buf_idx; + }, + '0' => self.state = .@"10", + '1' => self.state = .@"11", + '2' => self.state = .@"12", + '3' => self.state = .@"13", + else => self.state = .invalid, + }, + + .@"10" => switch (c) { + ';' => self.state = .query_fg_color, + '4' => { + self.command = .{ .reset_color = .{ + .kind = .{ .palette = 0 }, + .value = "", + } }; + + self.state = .reset_color_palette_index; + self.complete = true; + }, + else => self.state = .invalid, + }, + + .@"11" => switch (c) { + ';' => self.state = .query_bg_color, + '0' => { + self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + '1' => { + self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + '2' => { + self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + else => self.state = .invalid, + }, + + .@"12" => switch (c) { + ';' => self.state = .query_cursor_color, + else => self.state = .invalid, + }, + + .@"13" => switch (c) { + '3' => self.state = .@"133", + else => self.state = .invalid, + }, + + .@"133" => switch (c) { + ';' => self.state = .semantic_prompt, + else => self.state = .invalid, + }, + + .@"2" => switch (c) { + '2' => self.state = .@"22", + ';' => { + self.command = .{ .change_window_title = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_title }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"22" => switch (c) { + ';' => { + self.command = .{ .mouse_shape = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.mouse_shape.value }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"4" => switch (c) { + ';' => { + self.state = .color_palette_index; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .color_palette_index => switch (c) { + '0'...'9' => {}, + ';' => blk: { + const str = self.buf[self.buf_start .. self.buf_idx - 1]; + if (str.len == 0) { + self.state = .invalid; + break :blk; + } + + if (std.fmt.parseUnsigned(u8, str, 10)) |num| { + self.state = .color_palette_index_end; + self.temp_state = .{ .num = num }; + } else |err| switch (err) { + error.Overflow => self.state = .invalid, + error.InvalidCharacter => unreachable, + } + }, + else => self.state = .invalid, + }, + + .color_palette_index_end => switch (c) { + '?' => { + self.command = .{ .report_color = .{ + .kind = .{ .palette = @intCast(self.temp_state.num) }, + } }; + + self.complete = true; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .{ .palette = @intCast(self.temp_state.num) }, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .reset_color_palette_index => switch (c) { + ';' => { + self.state = .string; + self.temp_state = .{ .str = &self.command.reset_color.value }; + self.buf_start = self.buf_idx; + self.complete = false; + }, + else => { + self.state = .invalid; + self.complete = false; + }, + }, + + .@"5" => switch (c) { + '2' => self.state = .@"52", + else => self.state = .invalid, + }, + + .@"52" => switch (c) { + ';' => { + self.command = .{ .clipboard_contents = undefined }; + self.state = .clipboard_kind; + }, + else => self.state = .invalid, + }, + + .clipboard_kind => switch (c) { + ';' => { + self.command.clipboard_contents.kind = 'c'; + self.temp_state = .{ .str = &self.command.clipboard_contents.data }; + self.buf_start = self.buf_idx; + self.prepAllocableString(); + }, + else => { + self.command.clipboard_contents.kind = c; + self.state = .clipboard_kind_end; + }, + }, + + .clipboard_kind_end => switch (c) { + ';' => { + self.temp_state = .{ .str = &self.command.clipboard_contents.data }; + self.buf_start = self.buf_idx; + self.prepAllocableString(); + }, + else => self.state = .invalid, + }, + + .@"7" => switch (c) { + ';' => { + self.command = .{ .report_pwd = .{ .value = "" } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.report_pwd.value }; + self.buf_start = self.buf_idx; + }, + '7' => self.state = .@"77", + else => self.state = .invalid, + }, + + .@"77" => switch (c) { + '7' => self.state = .@"777", + else => self.state = .invalid, + }, + + .@"777" => switch (c) { + ';' => { + self.state = .rxvt_extension; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .rxvt_extension => switch (c) { + 'a'...'z' => {}, + ';' => { + const ext = self.buf[self.buf_start .. self.buf_idx - 1]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + self.state = .invalid; + return; + } + + self.command = .{ .show_desktop_notification = undefined }; + self.buf_start = self.buf_idx; + self.state = .notification_title; + }, + else => self.state = .invalid, + }, + + .notification_title => switch (c) { + ';' => { + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => {}, + }, + + .@"9" => switch (c) { + ';' => { + self.command = .{ .show_desktop_notification = .{ + .title = "", + .body = undefined, + } }; + + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => self.state = .invalid, + }, + + .query_fg_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .foreground } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .foreground, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .query_bg_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .background } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .background, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .query_cursor_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .cursor } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .cursor, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .semantic_prompt => switch (c) { + 'A' => { + self.state = .semantic_option_start; + self.command = .{ .prompt_start = .{} }; + self.complete = true; + }, + + 'B' => { + self.state = .semantic_option_start; + self.command = .{ .prompt_end = {} }; + self.complete = true; + }, + + 'C' => { + self.state = .semantic_option_start; + self.command = .{ .end_of_input = {} }; + self.complete = true; + }, + + 'D' => { + self.state = .semantic_exit_code_start; + self.command = .{ .end_of_command = .{} }; + self.complete = true; + }, + + else => self.state = .invalid, + }, + + .semantic_option_start => switch (c) { + ';' => { + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .semantic_option_key => switch (c) { + '=' => { + self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; + self.state = .semantic_option_value; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .semantic_option_value => switch (c) { + ';' => { + self.endSemanticOptionValue(); + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .semantic_exit_code_start => switch (c) { + ';' => { + // No longer complete, if ';' shows up we expect some code. + self.complete = false; + self.state = .semantic_exit_code; + self.temp_state = .{ .num = 0 }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .semantic_exit_code => switch (c) { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { + self.complete = true; + + const idx = self.buf_idx - self.buf_start; + if (idx > 0) self.temp_state.num *|= 10; + self.temp_state.num +|= c - '0'; + }, + ';' => { + self.endSemanticExitCode(); + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .allocable_string => { + const alloc = self.alloc.?; + const list = self.buf_dynamic.?; + list.append(alloc, c) catch { + self.state = .invalid; + return; + }; + + // Never consume buffer space for allocable strings + self.buf_idx -= 1; + + // We can complete at any time + self.complete = true; + }, + + .string => self.complete = true, + } + } + + fn prepAllocableString(self: *Parser) void { + assert(self.buf_dynamic == null); + + // We need an allocator. If we don't have an allocator, we + // pretend we're just a fixed buffer string and hope we fit! + const alloc = self.alloc orelse { + self.state = .string; + return; + }; + + // Allocate our dynamic buffer + const list = alloc.create(std.ArrayListUnmanaged(u8)) catch { + self.state = .string; + return; + }; + list.* = .{}; + + self.buf_dynamic = list; + self.state = .allocable_string; + } + + fn endSemanticOptionValue(self: *Parser) void { + const value = self.buf[self.buf_start..self.buf_idx]; + + if (mem.eql(u8, self.temp_state.key, "aid")) { + switch (self.command) { + .prompt_start => |*v| v.aid = value, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "redraw")) { + // Kitty supports a "redraw" option for prompt_start. I can't find + // this documented anywhere but can see in the code that this is used + // by shell environments to tell the terminal that the shell will NOT + // redraw the prompt so we should attempt to resize it. + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.redraw = false, + '1' => v.redraw = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid redraw value: {s}", .{value}); + } + }, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "k")) { + // The "k" marks the kind of prompt, or "primary" if we don't know. + // This can be used to distinguish between the first prompt, + // a continuation, etc. + switch (self.command) { + .prompt_start => |*v| if (value.len == 1) { + v.kind = switch (value[0]) { + 'c', 's' => .continuation, + 'r' => .right, + 'i' => .primary, + else => .primary, + }; + }, + else => {}, + } + } else log.info("unknown semantic prompts option: {s}", .{self.temp_state.key}); + } + + fn endSemanticExitCode(self: *Parser) void { + switch (self.command) { + .end_of_command => |*v| v.exit_code = @truncate(self.temp_state.num), + else => {}, + } + } + + fn endString(self: *Parser) void { + self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; + } + + fn endAllocableString(self: *Parser) void { + const list = self.buf_dynamic.?; + self.temp_state.str.* = list.items; + } + + /// End the sequence and return the command, if any. If the return value + /// is null, then no valid command was found. The optional terminator_ch + /// is the final character in the OSC sequence. This is used to determine + /// the response terminator. + pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { + if (!self.complete) { + log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); + return null; + } + + // Other cleanup we may have to do depending on state. + switch (self.state) { + .semantic_exit_code => self.endSemanticExitCode(), + .semantic_option_value => self.endSemanticOptionValue(), + .string => self.endString(), + .allocable_string => self.endAllocableString(), + else => {}, + } + + switch (self.command) { + .report_color => |*c| c.terminator = Terminator.init(terminator_ch), + else => {}, + } + + return self.command; + } +}; + +test "OSC: change_window_title" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('0'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC: change_window_title with 2" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('2'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC: change_window_title with utf8" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('2'); + p.next(';'); + // '—' EM DASH U+2014 (E2 80 94) + p.next(0xE2); + p.next(0x80); + p.next(0x94); + + p.next(' '); + // '‐' HYPHEN U+2010 (E2 80 90) + // Intententionally chosen to conflict with the 0x90 C1 control + p.next(0xE2); + p.next(0x80); + p.next(0x90); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("— ‐", cmd.change_window_title); +} + +test "OSC: change_window_icon" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('1'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_icon); + try testing.expectEqualStrings("ab", cmd.change_window_icon); +} + +test "OSC: prompt_start" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expect(cmd.prompt_start.redraw); +} + +test "OSC: prompt_start with single option" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); +} + +test "OSC: prompt_start with redraw disabled" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC: prompt_start with redraw invalid value" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;redraw=42"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.redraw); + try testing.expect(cmd.prompt_start.kind == .primary); +} + +test "OSC: prompt_start with continuation" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .continuation); +} + +test "OSC: end_of_command no exit code" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_command); +} + +test "OSC: end_of_command with exit code" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;D;25"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_command); + try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); +} + +test "OSC: prompt_end" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_end); +} + +test "OSC: end_of_input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_input); +} + +test "OSC: reset cursor color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "112"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .reset_color); + try testing.expectEqual(cmd.reset_color.kind, .cursor); +} + +test "OSC: get/set clipboard" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: get/set clipboard (optional parameter)" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "52;;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: get/set clipboard with allocator" { + const testing = std.testing; + const alloc = testing.allocator; + + var p: Parser = .{ .alloc = alloc }; + defer p.deinit(); + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: report pwd" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;file:///tmp/example"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .report_pwd); + try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); +} + +test "OSC: pointer cursor" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "22;pointer"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .mouse_shape); + try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); +} + +test "OSC: report pwd empty" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); +} + +test "OSC: longer than buffer" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "a" ** (Parser.MAX_BUF + 2); + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); +} + +test "OSC: report default foreground color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "10;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = ESC followed by \ + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(cmd.report_color.kind, .foreground); + try testing.expectEqual(cmd.report_color.terminator, .st); +} + +test "OSC: set foreground color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "10;rgbi:0.0/0.5/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x07').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(cmd.set_color.kind, .foreground); + try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); +} + +test "OSC: report default background color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "11;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(cmd.report_color.kind, .background); + try testing.expectEqual(cmd.report_color.terminator, .bel); +} + +test "OSC: set background color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "11;rgb:f/ff/ffff"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(cmd.set_color.kind, .background); + try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); +} + +test "OSC: get palette color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); + try testing.expectEqual(cmd.report_color.terminator, .st); +} + +test "OSC: set palette color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;17;rgb:aa/bb/cc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); + try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); +} + +test "OSC: show desktop notification" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world"); +} + +test "OSC: show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "777;notify;Title;Body"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); +} + +test "OSC: empty param" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} diff --git a/src/terminal2/parse_table.zig b/src/terminal2/parse_table.zig new file mode 100644 index 0000000000..66c443783c --- /dev/null +++ b/src/terminal2/parse_table.zig @@ -0,0 +1,389 @@ +//! The primary export of this file is "table", which contains a +//! comptime-generated state transition table for VT emulation. +//! +//! This is based on the vt100.net state machine: +//! https://vt100.net/emu/dec_ansi_parser +//! But has some modifications: +//! +//! * csi_param accepts the colon character (':') since the SGR command +//! accepts colon as a valid parameter value. +//! + +const std = @import("std"); +const builtin = @import("builtin"); +const parser = @import("Parser.zig"); +const State = parser.State; +const Action = parser.TransitionAction; + +/// The state transition table. The type is [u8][State]Transition but +/// comptime-generated to be exactly-sized. +pub const table = genTable(); + +/// Table is the type of the state table. This is dynamically (comptime) +/// generated to be exactly sized. +pub const Table = genTableType(false); + +/// OptionalTable is private to this file. We use this to accumulate and +/// detect invalid transitions created. +const OptionalTable = genTableType(true); + +// Transition is the transition to take within the table +pub const Transition = struct { + state: State, + action: Action, +}; + +/// Table is the type of the state transition table. +fn genTableType(comptime optional: bool) type { + const max_u8 = std.math.maxInt(u8); + const stateInfo = @typeInfo(State); + const max_state = stateInfo.Enum.fields.len; + const Elem = if (optional) ?Transition else Transition; + return [max_u8 + 1][max_state]Elem; +} + +/// Function to generate the full state transition table for VT emulation. +fn genTable() Table { + @setEvalBranchQuota(20000); + + // We accumulate using an "optional" table so we can detect duplicates. + var result: OptionalTable = undefined; + for (0..result.len) |i| { + for (0..result[0].len) |j| { + result[i][j] = null; + } + } + + // anywhere transitions + const stateInfo = @typeInfo(State); + inline for (stateInfo.Enum.fields) |field| { + const source: State = @enumFromInt(field.value); + + // anywhere => ground + single(&result, 0x18, source, .ground, .execute); + single(&result, 0x1A, source, .ground, .execute); + range(&result, 0x80, 0x8F, source, .ground, .execute); + range(&result, 0x91, 0x97, source, .ground, .execute); + single(&result, 0x99, source, .ground, .execute); + single(&result, 0x9A, source, .ground, .execute); + single(&result, 0x9C, source, .ground, .none); + + // anywhere => escape + single(&result, 0x1B, source, .escape, .none); + + // anywhere => sos_pm_apc_string + single(&result, 0x98, source, .sos_pm_apc_string, .none); + single(&result, 0x9E, source, .sos_pm_apc_string, .none); + single(&result, 0x9F, source, .sos_pm_apc_string, .none); + + // anywhere => csi_entry + single(&result, 0x9B, source, .csi_entry, .none); + + // anywhere => dcs_entry + single(&result, 0x90, source, .dcs_entry, .none); + + // anywhere => osc_string + single(&result, 0x9D, source, .osc_string, .none); + } + + // ground + { + // events + single(&result, 0x19, .ground, .ground, .execute); + range(&result, 0, 0x17, .ground, .ground, .execute); + range(&result, 0x1C, 0x1F, .ground, .ground, .execute); + range(&result, 0x20, 0x7F, .ground, .ground, .print); + } + + // escape_intermediate + { + const source = State.escape_intermediate; + + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x30, 0x7E, source, .ground, .esc_dispatch); + } + + // sos_pm_apc_string + { + const source = State.sos_pm_apc_string; + + // events + single(&result, 0x19, source, source, .apc_put); + range(&result, 0, 0x17, source, source, .apc_put); + range(&result, 0x1C, 0x1F, source, source, .apc_put); + range(&result, 0x20, 0x7F, source, source, .apc_put); + } + + // escape + { + const source = State.escape; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x30, 0x4F, source, .ground, .esc_dispatch); + range(&result, 0x51, 0x57, source, .ground, .esc_dispatch); + range(&result, 0x60, 0x7E, source, .ground, .esc_dispatch); + single(&result, 0x59, source, .ground, .esc_dispatch); + single(&result, 0x5A, source, .ground, .esc_dispatch); + single(&result, 0x5C, source, .ground, .esc_dispatch); + + // => escape_intermediate + range(&result, 0x20, 0x2F, source, .escape_intermediate, .collect); + + // => sos_pm_apc_string + single(&result, 0x58, source, .sos_pm_apc_string, .none); + single(&result, 0x5E, source, .sos_pm_apc_string, .none); + single(&result, 0x5F, source, .sos_pm_apc_string, .none); + + // => dcs_entry + single(&result, 0x50, source, .dcs_entry, .none); + + // => csi_entry + single(&result, 0x5B, source, .csi_entry, .none); + + // => osc_string + single(&result, 0x5D, source, .osc_string, .none); + } + + // dcs_entry + { + const source = State.dcs_entry; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_intermediate + range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); + + // => dcs_ignore + single(&result, 0x3A, source, .dcs_ignore, .none); + + // => dcs_param + range(&result, 0x30, 0x39, source, .dcs_param, .param); + single(&result, 0x3B, source, .dcs_param, .param); + range(&result, 0x3C, 0x3F, source, .dcs_param, .collect); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_intermediate + { + const source = State.dcs_intermediate; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_ignore + range(&result, 0x30, 0x3F, source, .dcs_ignore, .none); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_ignore + { + const source = State.dcs_ignore; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + } + + // dcs_param + { + const source = State.dcs_param; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x30, 0x39, source, source, .param); + single(&result, 0x3B, source, source, .param); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_ignore + single(&result, 0x3A, source, .dcs_ignore, .none); + range(&result, 0x3C, 0x3F, source, .dcs_ignore, .none); + + // => dcs_intermediate + range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_passthrough + { + const source = State.dcs_passthrough; + + // events + single(&result, 0x19, source, source, .put); + range(&result, 0, 0x17, source, source, .put); + range(&result, 0x1C, 0x1F, source, source, .put); + range(&result, 0x20, 0x7E, source, source, .put); + single(&result, 0x7F, source, source, .ignore); + } + + // csi_param + { + const source = State.csi_param; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x30, 0x39, source, source, .param); + single(&result, 0x3A, source, source, .param); + single(&result, 0x3B, source, source, .param); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + range(&result, 0x3C, 0x3F, source, .csi_ignore, .none); + + // => csi_intermediate + range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); + } + + // csi_ignore + { + const source = State.csi_ignore; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x3F, source, source, .ignore); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .none); + } + + // csi_intermediate + { + const source = State.csi_intermediate; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + range(&result, 0x30, 0x3F, source, .csi_ignore, .none); + } + + // csi_entry + { + const source = State.csi_entry; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + single(&result, 0x3A, source, .csi_ignore, .none); + + // => csi_intermediate + range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); + + // => csi_param + range(&result, 0x30, 0x39, source, .csi_param, .param); + single(&result, 0x3B, source, .csi_param, .param); + range(&result, 0x3C, 0x3F, source, .csi_param, .collect); + } + + // osc_string + { + const source = State.osc_string; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x06, source, source, .ignore); + range(&result, 0x08, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x20, 0xFF, source, source, .osc_put); + + // XTerm accepts either BEL or ST for terminating OSC + // sequences, and when returning information, uses the same + // terminator used in a query. + single(&result, 0x07, source, .ground, .none); + } + + // Create our immutable version + var final: Table = undefined; + for (0..final.len) |i| { + for (0..final[0].len) |j| { + final[i][j] = result[i][j] orelse transition(@enumFromInt(j), .none); + } + } + + return final; +} + +fn single(t: *OptionalTable, c: u8, s0: State, s1: State, a: Action) void { + const s0_int = @intFromEnum(s0); + + // TODO: enable this but it thinks we're in runtime right now + // if (t[c][s0_int]) |existing| { + // @compileLog(c); + // @compileLog(s0); + // @compileLog(s1); + // @compileLog(existing); + // @compileError("transition set multiple times"); + // } + + t[c][s0_int] = transition(s1, a); +} + +fn range(t: *OptionalTable, from: u8, to: u8, s0: State, s1: State, a: Action) void { + var i = from; + while (i <= to) : (i += 1) { + single(t, i, s0, s1, a); + // If 'to' is 0xFF, our next pass will overflow. Return early to prevent + // the loop from executing it's continue expression + if (i == to) break; + } +} + +fn transition(state: State, action: Action) Transition { + return .{ .state = state, .action = action }; +} + +test { + // This forces comptime-evaluation of table, so we're just testing + // that it succeeds in creation. + _ = table; +} diff --git a/src/terminal2/sanitize.zig b/src/terminal2/sanitize.zig new file mode 100644 index 0000000000..f492291aa2 --- /dev/null +++ b/src/terminal2/sanitize.zig @@ -0,0 +1,13 @@ +const std = @import("std"); + +/// Returns true if the data looks safe to paste. +pub fn isSafePaste(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null; +} + +test isSafePaste { + const testing = std.testing; + try testing.expect(isSafePaste("hello")); + try testing.expect(!isSafePaste("hello\n")); + try testing.expect(!isSafePaste("hello\nworld")); +} diff --git a/src/terminal2/stream.zig b/src/terminal2/stream.zig new file mode 100644 index 0000000000..fc97d36850 --- /dev/null +++ b/src/terminal2/stream.zig @@ -0,0 +1,2014 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const simd = @import("../simd/main.zig"); +const Parser = @import("Parser.zig"); +const ansi = @import("ansi.zig"); +const charsets = @import("charsets.zig"); +const device_status = @import("device_status.zig"); +const csi = @import("csi.zig"); +const kitty = @import("kitty.zig"); +const modes = @import("modes.zig"); +const osc = @import("osc.zig"); +const sgr = @import("sgr.zig"); +const UTF8Decoder = @import("UTF8Decoder.zig"); +const MouseShape = @import("mouse_shape.zig").MouseShape; + +const log = std.log.scoped(.stream); + +/// Returns a type that can process a stream of tty control characters. +/// This will call various callback functions on type T. Type T only has to +/// implement the callbacks it cares about; any unimplemented callbacks will +/// logged at runtime. +/// +/// To figure out what callbacks exist, search the source for "hasDecl". This +/// isn't ideal but for now that's the best approach. +/// +/// This is implemented this way because we purposely do NOT want dynamic +/// dispatch for performance reasons. The way this is implemented forces +/// comptime resolution for all function calls. +pub fn Stream(comptime Handler: type) type { + return struct { + const Self = @This(); + + // We use T with @hasDecl so it needs to be a struct. Unwrap the + // pointer if we were given one. + const T = switch (@typeInfo(Handler)) { + .Pointer => |p| p.child, + else => Handler, + }; + + handler: Handler, + parser: Parser = .{}, + utf8decoder: UTF8Decoder = .{}, + + pub fn deinit(self: *Self) void { + self.parser.deinit(); + } + + /// Process a string of characters. + pub fn nextSlice(self: *Self, input: []const u8) !void { + // This is the maximum number of codepoints we can decode + // at one time for this function call. This is somewhat arbitrary + // so if someone can demonstrate a better number then we can switch. + var cp_buf: [4096]u32 = undefined; + + // Split the input into chunks that fit into cp_buf. + var i: usize = 0; + while (true) { + const len = @min(cp_buf.len, input.len - i); + try self.nextSliceCapped(input[i .. i + len], &cp_buf); + i += len; + if (i >= input.len) break; + } + } + + fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void { + assert(input.len <= cp_buf.len); + + var offset: usize = 0; + + // If the scalar UTF-8 decoder was in the middle of processing + // a code sequence, we continue until it's not. + while (self.utf8decoder.state != 0) { + if (offset >= input.len) return; + try self.nextUtf8(input[offset]); + offset += 1; + } + if (offset >= input.len) return; + + // If we're not in the ground state then we process until + // we are. This can happen if the last chunk of input put us + // in the middle of a control sequence. + offset += try self.consumeUntilGround(input[offset..]); + if (offset >= input.len) return; + offset += try self.consumeAllEscapes(input[offset..]); + + // If we're in the ground state then we can use SIMD to process + // input until we see an ESC (0x1B), since all other characters + // up to that point are just UTF-8. + while (self.parser.state == .ground and offset < input.len) { + const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); + for (cp_buf[0..res.decoded]) |cp| { + if (cp <= 0xF) { + try self.execute(@intCast(cp)); + } else { + try self.print(@intCast(cp)); + } + } + // Consume the bytes we just processed. + offset += res.consumed; + + if (offset >= input.len) return; + + // If our offset is NOT an escape then we must have a + // partial UTF-8 sequence. In that case, we pass it off + // to the scalar parser. + if (input[offset] != 0x1B) { + const rem = input[offset..]; + for (rem) |c| try self.nextUtf8(c); + return; + } + + // Process control sequences until we run out. + offset += try self.consumeAllEscapes(input[offset..]); + } + } + + /// Parses back-to-back escape sequences until none are left. + /// Returns the number of bytes consumed from the provided input. + /// + /// Expects input to start with 0x1B, use consumeUntilGround first + /// if the stream may be in the middle of an escape sequence. + fn consumeAllEscapes(self: *Self, input: []const u8) !usize { + var offset: usize = 0; + while (input[offset] == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + offset += 1; + offset += try self.consumeUntilGround(input[offset..]); + if (offset >= input.len) return input.len; + } + return offset; + } + + /// Parses escape sequences until the parser reaches the ground state. + /// Returns the number of bytes consumed from the provided input. + fn consumeUntilGround(self: *Self, input: []const u8) !usize { + var offset: usize = 0; + while (self.parser.state != .ground) { + if (offset >= input.len) return input.len; + try self.nextNonUtf8(input[offset]); + offset += 1; + } + return offset; + } + + /// Like nextSlice but takes one byte and is necessarilly a scalar + /// operation that can't use SIMD. Prefer nextSlice if you can and + /// try to get multiple bytes at once. + pub fn next(self: *Self, c: u8) !void { + // The scalar path can be responsible for decoding UTF-8. + if (self.parser.state == .ground and c != 0x1B) { + try self.nextUtf8(c); + return; + } + + try self.nextNonUtf8(c); + } + + /// Process the next byte and print as necessary. + /// + /// This assumes we're in the UTF-8 decoding state. If we may not + /// be in the UTF-8 decoding state call nextSlice or next. + fn nextUtf8(self: *Self, c: u8) !void { + assert(self.parser.state == .ground and c != 0x1B); + + const res = self.utf8decoder.next(c); + const consumed = res[1]; + if (res[0]) |codepoint| { + if (codepoint <= 0xF) { + try self.execute(@intCast(codepoint)); + } else { + try self.print(@intCast(codepoint)); + } + } + if (!consumed) { + const retry = self.utf8decoder.next(c); + // It should be impossible for the decoder + // to not consume the byte twice in a row. + assert(retry[1] == true); + if (retry[0]) |codepoint| { + if (codepoint <= 0xF) { + try self.execute(@intCast(codepoint)); + } else { + try self.print(@intCast(codepoint)); + } + } + } + } + + /// Process the next character and call any callbacks if necessary. + /// + /// This assumes that we're not in the UTF-8 decoding state. If + /// we may be in the UTF-8 decoding state call nextSlice or next. + fn nextNonUtf8(self: *Self, c: u8) !void { + assert(self.parser.state != .ground or c == 0x1B); + + // Fast path for ESC + if (self.parser.state == .ground and c == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return; + } + // Fast path for CSI entry. + if (self.parser.state == .escape and c == '[') { + self.parser.state = .csi_entry; + return; + } + // Fast path for CSI params. + if (self.parser.state == .csi_param) csi_param: { + switch (c) { + // A C0 escape (yes, this is valid): + 0x00...0x0F => try self.execute(c), + // We ignore C0 escapes > 0xF since execute + // doesn't have processing for them anyway: + 0x10...0x17, 0x19, 0x1C...0x1F => {}, + // We don't currently have any handling for + // 0x18 or 0x1A, but they should still move + // the parser state to ground. + 0x18, 0x1A => self.parser.state = .ground, + // A parameter digit: + '0'...'9' => if (self.parser.params_idx < 16) { + self.parser.param_acc *|= 10; + self.parser.param_acc +|= c - '0'; + // The parser's CSI param action uses param_acc_idx + // to decide if there's a final param that needs to + // be consumed or not, but it doesn't matter really + // what it is as long as it's not 0. + self.parser.param_acc_idx |= 1; + }, + // A parameter separator: + ':', ';' => if (self.parser.params_idx < 16) { + self.parser.params[self.parser.params_idx] = self.parser.param_acc; + self.parser.params_idx += 1; + + self.parser.param_acc = 0; + self.parser.param_acc_idx = 0; + + // Keep track of separator state. + const sep: Parser.ParamSepState = @enumFromInt(c); + if (self.parser.params_idx == 1) self.parser.params_sep = sep; + if (self.parser.params_sep != sep) self.parser.params_sep = .mixed; + }, + // Explicitly ignored: + 0x7F => {}, + // Defer to the state machine to + // handle any other characters: + else => break :csi_param, + } + return; + } + + const actions = self.parser.next(c); + for (actions) |action_opt| { + const action = action_opt orelse continue; + + // if (action != .print) { + // log.info("action: {}", .{action}); + // } + + // If this handler handles everything manually then we do nothing + // if it can be processed. + if (@hasDecl(T, "handleManually")) { + const processed = self.handler.handleManually(action) catch |err| err: { + log.warn("error handling action manually err={} action={}", .{ + err, + action, + }); + + break :err false; + }; + + if (processed) continue; + } + + switch (action) { + .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), + .execute => |code| try self.execute(code), + .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), + .esc_dispatch => |esc| try self.escDispatch(esc), + .osc_dispatch => |cmd| try self.oscDispatch(cmd), + .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { + try self.handler.dcsHook(dcs); + } else log.warn("unimplemented DCS hook", .{}), + .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { + try self.handler.dcsPut(code); + } else log.warn("unimplemented DCS put: {x}", .{code}), + .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { + try self.handler.dcsUnhook(); + } else log.warn("unimplemented DCS unhook", .{}), + .apc_start => if (@hasDecl(T, "apcStart")) { + try self.handler.apcStart(); + } else log.warn("unimplemented APC start", .{}), + .apc_put => |code| if (@hasDecl(T, "apcPut")) { + try self.handler.apcPut(code); + } else log.warn("unimplemented APC put: {x}", .{code}), + .apc_end => if (@hasDecl(T, "apcEnd")) { + try self.handler.apcEnd(); + } else log.warn("unimplemented APC end", .{}), + } + } + } + + pub fn print(self: *Self, c: u21) !void { + if (@hasDecl(T, "print")) { + try self.handler.print(c); + } + } + + pub fn execute(self: *Self, c: u8) !void { + switch (@as(ansi.C0, @enumFromInt(c))) { + // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 + .NUL, .SOH, .STX => {}, + + .ENQ => if (@hasDecl(T, "enquiry")) + try self.handler.enquiry() + else + log.warn("unimplemented execute: {x}", .{c}), + + .BEL => if (@hasDecl(T, "bell")) + try self.handler.bell() + else + log.warn("unimplemented execute: {x}", .{c}), + + .BS => if (@hasDecl(T, "backspace")) + try self.handler.backspace() + else + log.warn("unimplemented execute: {x}", .{c}), + + .HT => if (@hasDecl(T, "horizontalTab")) + try self.handler.horizontalTab(1) + else + log.warn("unimplemented execute: {x}", .{c}), + + .LF, .VT, .FF => if (@hasDecl(T, "linefeed")) + try self.handler.linefeed() + else + log.warn("unimplemented execute: {x}", .{c}), + + .CR => if (@hasDecl(T, "carriageReturn")) + try self.handler.carriageReturn() + else + log.warn("unimplemented execute: {x}", .{c}), + + .SO => if (@hasDecl(T, "invokeCharset")) + try self.handler.invokeCharset(.GL, .G1, false) + else + log.warn("unimplemented invokeCharset: {x}", .{c}), + + .SI => if (@hasDecl(T, "invokeCharset")) + try self.handler.invokeCharset(.GL, .G0, false) + else + log.warn("unimplemented invokeCharset: {x}", .{c}), + + else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), + } + } + + fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + // Handles aliases first + const action = switch (input.final) { + // Alias for set cursor position + 'f' => blk: { + var copy = input; + copy.final = 'H'; + break :blk copy; + }, + + else => input, + }; + + switch (action.final) { + // CUU - Cursor Up + 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor up command: {}", .{action}); + return; + }, + }, + false, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUD - Cursor Down + 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor down command: {}", .{action}); + return; + }, + }, + false, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUF - Cursor Right + 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor right command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUB - Cursor Left + 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor left command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CNL - Cursor Next Line + 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor up command: {}", .{action}); + return; + }, + }, + true, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CPL - Cursor Previous Line + 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor down command: {}", .{action}); + return; + }, + }, + true, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // HPA - Cursor Horizontal Position Absolute + // TODO: test + 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (action.params.len) { + 0 => try self.handler.setCursorCol(1), + 1 => try self.handler.setCursorCol(action.params[0]), + else => log.warn("invalid HPA command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUP - Set Cursor Position. + // TODO: test + 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (action.params.len) { + 0 => try self.handler.setCursorPos(1, 1), + 1 => try self.handler.setCursorPos(action.params[0], 1), + 2 => try self.handler.setCursorPos(action.params[0], action.params[1]), + else => log.warn("invalid CUP command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // CHT - Cursor Horizontal Tabulation + 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid horizontal tab command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Erase Display + 'J' => if (@hasDecl(T, "eraseDisplay")) { + const protected_: ?bool = switch (action.intermediates.len) { + 0 => false, + 1 => if (action.intermediates[0] == '?') true else null, + else => null, + }; + + const protected = protected_ orelse { + log.warn("invalid erase display command: {}", .{action}); + return; + }; + + const mode_: ?csi.EraseDisplay = switch (action.params.len) { + 0 => .below, + 1 => if (action.params[0] <= 3) + std.meta.intToEnum(csi.EraseDisplay, action.params[0]) catch null + else + null, + else => null, + }; + + const mode = mode_ orelse { + log.warn("invalid erase display command: {}", .{action}); + return; + }; + + try self.handler.eraseDisplay(mode, protected); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // Erase Line + 'K' => if (@hasDecl(T, "eraseLine")) { + const protected_: ?bool = switch (action.intermediates.len) { + 0 => false, + 1 => if (action.intermediates[0] == '?') true else null, + else => null, + }; + + const protected = protected_ orelse { + log.warn("invalid erase line command: {}", .{action}); + return; + }; + + const mode_: ?csi.EraseLine = switch (action.params.len) { + 0 => .right, + 1 => if (action.params[0] < 3) @enumFromInt(action.params[0]) else null, + else => null, + }; + + const mode = mode_ orelse { + log.warn("invalid erase line command: {}", .{action}); + return; + }; + + try self.handler.eraseLine(mode, protected); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // IL - Insert Lines + // TODO: test + 'L' => if (@hasDecl(T, "insertLines")) switch (action.params.len) { + 0 => try self.handler.insertLines(1), + 1 => try self.handler.insertLines(action.params[0]), + else => log.warn("invalid IL command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // DL - Delete Lines + // TODO: test + 'M' => if (@hasDecl(T, "deleteLines")) switch (action.params.len) { + 0 => try self.handler.deleteLines(1), + 1 => try self.handler.deleteLines(action.params[0]), + else => log.warn("invalid DL command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // Delete Character (DCH) + 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid delete characters command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Scroll Up (SD) + + 'S' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "scrollUp")) try self.handler.scrollUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid scroll up command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + else => log.warn( + "ignoring unimplemented CSI S with intermediates: {s}", + .{action.intermediates}, + ), + }, + + // Scroll Down (SD) + 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid scroll down command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Cursor Tabulation Control + 'W' => { + switch (action.params.len) { + 0 => if (action.intermediates.len == 1 and action.intermediates[0] == '?') { + if (@hasDecl(T, "tabReset")) + try self.handler.tabReset() + else + log.warn("unimplemented tab reset callback: {}", .{action}); + }, + + 1 => switch (action.params[0]) { + 0 => if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{action}), + + 2 => if (@hasDecl(T, "tabClear")) + try self.handler.tabClear(.current) + else + log.warn("unimplemented tab clear callback: {}", .{action}), + + 5 => if (@hasDecl(T, "tabClear")) + try self.handler.tabClear(.all) + else + log.warn("unimplemented tab clear callback: {}", .{action}), + + else => {}, + }, + + else => {}, + } + + log.warn("invalid cursor tabulation control: {}", .{action}); + return; + }, + + // Erase Characters (ECH) + 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid erase characters command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CHT - Cursor Horizontal Tabulation Back + 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid horizontal tab back command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // HPR - Cursor Horizontal Position Relative + 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid HPR command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Repeat Previous Char (REP) + 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid print repeat command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // c - Device Attributes (DA1) + 'c' => if (@hasDecl(T, "deviceAttributes")) { + const req: ansi.DeviceAttributeReq = switch (action.intermediates.len) { + 0 => ansi.DeviceAttributeReq.primary, + 1 => switch (action.intermediates[0]) { + '>' => ansi.DeviceAttributeReq.secondary, + '=' => ansi.DeviceAttributeReq.tertiary, + else => null, + }, + else => @as(?ansi.DeviceAttributeReq, null), + } orelse { + log.warn("invalid device attributes command: {}", .{action}); + return; + }; + + try self.handler.deviceAttributes(req, action.params); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // VPA - Cursor Vertical Position Absolute + 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid VPA command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // VPR - Cursor Vertical Position Relative + 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid VPR command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // TBC - Tab Clear + // TODO: test + 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( + switch (action.params.len) { + 1 => @enumFromInt(action.params[0]), + else => { + log.warn("invalid tab clear command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // SM - Set Mode + 'h' => if (@hasDecl(T, "setMode")) mode: { + const ansi_mode = ansi: { + if (action.intermediates.len == 0) break :ansi true; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :ansi false; + + log.warn("invalid set mode command: {}", .{action}); + break :mode; + }; + + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { + try self.handler.setMode(mode, true); + } else { + log.warn("unimplemented mode: {}", .{mode_int}); + } + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // RM - Reset Mode + 'l' => if (@hasDecl(T, "setMode")) mode: { + const ansi_mode = ansi: { + if (action.intermediates.len == 0) break :ansi true; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :ansi false; + + log.warn("invalid set mode command: {}", .{action}); + break :mode; + }; + + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { + try self.handler.setMode(mode, false); + } else { + log.warn("unimplemented mode: {}", .{mode_int}); + } + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // SGR - Select Graphic Rendition + 'm' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "setAttribute")) { + // log.info("parse SGR params={any}", .{action.params}); + var p: sgr.Parser = .{ .params = action.params, .colon = action.sep == .colon }; + while (p.next()) |attr| { + // log.info("SGR attribute: {}", .{attr}); + try self.handler.setAttribute(attr); + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + 1 => switch (action.intermediates[0]) { + '>' => if (@hasDecl(T, "setModifyKeyFormat")) blk: { + if (action.params.len == 0) { + // Reset + try self.handler.setModifyKeyFormat(.{ .legacy = {} }); + break :blk; + } + + var format: ansi.ModifyKeyFormat = switch (action.params[0]) { + 0 => .{ .legacy = {} }, + 1 => .{ .cursor_keys = {} }, + 2 => .{ .function_keys = {} }, + 4 => .{ .other_keys = .none }, + else => { + log.warn("invalid setModifyKeyFormat: {}", .{action}); + break :blk; + }, + }; + + if (action.params.len > 2) { + log.warn("invalid setModifyKeyFormat: {}", .{action}); + break :blk; + } + + if (action.params.len == 2) { + switch (format) { + // We don't support any of the subparams yet for these. + .legacy => {}, + .cursor_keys => {}, + .function_keys => {}, + + // We only support the numeric form. + .other_keys => |*v| switch (action.params[1]) { + 2 => v.* = .numeric, + else => v.* = .none, + }, + } + } + + try self.handler.setModifyKeyFormat(format); + } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), + + else => log.warn( + "unknown CSI m with intermediate: {}", + .{action.intermediates[0]}, + ), + }, + + else => { + // Nothing, but I wanted a place to put this comment: + // there are others forms of CSI m that have intermediates. + // `vim --clean` uses `CSI ? 4 m` and I don't know what + // that means. And there is also `CSI > m` which is used + // to control modifier key reporting formats that we don't + // support yet. + log.warn( + "ignoring unimplemented CSI m with intermediates: {s}", + .{action.intermediates}, + ); + }, + }, + + // TODO: test + 'n' => { + // Handle deviceStatusReport first + if (action.intermediates.len == 0 or + action.intermediates[0] == '?') + { + if (!@hasDecl(T, "deviceStatusReport")) { + log.warn("unimplemented CSI callback: {}", .{action}); + return; + } + + if (action.params.len != 1) { + log.warn("invalid device status report command: {}", .{action}); + return; + } + + const question = question: { + if (action.intermediates.len == 0) break :question false; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :question true; + + log.warn("invalid set mode command: {}", .{action}); + return; + }; + + const req = device_status.reqFromInt(action.params[0], question) orelse { + log.warn("invalid device status report command: {}", .{action}); + return; + }; + + try self.handler.deviceStatusReport(req); + return; + } + + // Handle other forms of CSI n + switch (action.intermediates.len) { + 0 => unreachable, // handled above + + 1 => switch (action.intermediates[0]) { + '>' => if (@hasDecl(T, "setModifyKeyFormat")) { + // This isn't strictly correct. CSI > n has parameters that + // control what exactly is being disabled. However, we + // only support reverting back to modify other keys in + // numeric except format. + try self.handler.setModifyKeyFormat(.{ .other_keys = .numeric_except }); + } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), + + else => log.warn( + "unknown CSI n with intermediate: {}", + .{action.intermediates[0]}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI n with intermediates: {s}", + .{action.intermediates}, + ), + } + }, + + // DECRQM - Request Mode + 'p' => switch (action.intermediates.len) { + 2 => decrqm: { + const ansi_mode = ansi: { + switch (action.intermediates.len) { + 1 => if (action.intermediates[0] == '$') break :ansi true, + 2 => if (action.intermediates[0] == '?' and + action.intermediates[1] == '$') break :ansi false, + else => {}, + } + + log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ); + break :decrqm; + }; + + if (action.params.len != 1) { + log.warn("invalid DECRQM command: {}", .{action}); + break :decrqm; + } + + if (@hasDecl(T, "requestMode")) { + try self.handler.requestMode(action.params[0], ansi_mode); + } else log.warn("unimplemented DECRQM callback: {}", .{action}); + }, + + else => log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ), + }, + + 'q' => switch (action.intermediates.len) { + 1 => switch (action.intermediates[0]) { + // DECSCUSR - Select Cursor Style + // TODO: test + ' ' => { + if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( + switch (action.params.len) { + 0 => ansi.CursorStyle.default, + 1 => @enumFromInt(action.params[0]), + else => { + log.warn("invalid set curor style command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}); + }, + + // DECSCA + '"' => { + if (@hasDecl(T, "setProtectedMode")) { + const mode_: ?ansi.ProtectedMode = switch (action.params.len) { + else => null, + 0 => .off, + 1 => switch (action.params[0]) { + 0, 2 => .off, + 1 => .dec, + else => null, + }, + }; + + const mode = mode_ orelse { + log.warn("invalid set protected mode command: {}", .{action}); + return; + }; + + try self.handler.setProtectedMode(mode); + } else log.warn("unimplemented CSI callback: {}", .{action}); + }, + + // XTVERSION + '>' => { + if (@hasDecl(T, "reportXtversion")) try self.handler.reportXtversion(); + }, + else => { + log.warn( + "ignoring unimplemented CSI q with intermediates: {s}", + .{action.intermediates}, + ); + }, + }, + + else => log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ), + }, + + 'r' => switch (action.intermediates.len) { + // DECSTBM - Set Top and Bottom Margins + 0 => if (@hasDecl(T, "setTopAndBottomMargin")) { + switch (action.params.len) { + 0 => try self.handler.setTopAndBottomMargin(0, 0), + 1 => try self.handler.setTopAndBottomMargin(action.params[0], 0), + 2 => try self.handler.setTopAndBottomMargin(action.params[0], action.params[1]), + else => log.warn("invalid DECSTBM command: {}", .{action}), + } + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + 1 => switch (action.intermediates[0]) { + // Restore Mode + '?' => if (@hasDecl(T, "restoreMode")) { + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.restoreMode(mode); + } else { + log.warn( + "unimplemented restore mode: {}", + .{mode_int}, + ); + } + } + }, + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI s with intermediates: {s}", + .{action}, + ), + }, + + 's' => switch (action.intermediates.len) { + // DECSLRM + 0 => if (@hasDecl(T, "setLeftAndRightMargin")) { + switch (action.params.len) { + // CSI S is ambiguous with zero params so we defer + // to our handler to do the proper logic. If mode 69 + // is set, then we should invoke DECSLRM, otherwise + // we should invoke SC. + 0 => try self.handler.setLeftAndRightMarginAmbiguous(), + 1 => try self.handler.setLeftAndRightMargin(action.params[0], 0), + 2 => try self.handler.setLeftAndRightMargin(action.params[0], action.params[1]), + else => log.warn("invalid DECSLRM command: {}", .{action}), + } + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + 1 => switch (action.intermediates[0]) { + '?' => if (@hasDecl(T, "saveMode")) { + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.saveMode(mode); + } else { + log.warn( + "unimplemented save mode: {}", + .{mode_int}, + ); + } + } + }, + + // XTSHIFTESCAPE + '>' => if (@hasDecl(T, "setMouseShiftCapture")) capture: { + const capture = switch (action.params.len) { + 0 => false, + 1 => switch (action.params[0]) { + 0 => false, + 1 => true, + else => { + log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); + break :capture; + }, + }, + else => { + log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); + break :capture; + }, + }; + + try self.handler.setMouseShiftCapture(capture); + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI s with intermediates: {s}", + .{action}, + ), + }, + + 'u' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "restoreCursor")) + try self.handler.restoreCursor() + else + log.warn("unimplemented CSI callback: {}", .{action}), + + // Kitty keyboard protocol + 1 => switch (action.intermediates[0]) { + '?' => if (@hasDecl(T, "queryKittyKeyboard")) { + try self.handler.queryKittyKeyboard(); + }, + + '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { + const flags: u5 = if (action.params.len == 1) + std.math.cast(u5, action.params[0]) orelse { + log.warn("invalid pushKittyKeyboard command: {}", .{action}); + break :push; + } + else + 0; + + try self.handler.pushKittyKeyboard(@bitCast(flags)); + }, + + '<' => if (@hasDecl(T, "popKittyKeyboard")) { + const number: u16 = if (action.params.len == 1) + action.params[0] + else + 1; + + try self.handler.popKittyKeyboard(number); + }, + + '=' => if (@hasDecl(T, "setKittyKeyboard")) set: { + const flags: u5 = if (action.params.len >= 1) + std.math.cast(u5, action.params[0]) orelse { + log.warn("invalid setKittyKeyboard command: {}", .{action}); + break :set; + } + else + 0; + + const number: u16 = if (action.params.len >= 2) + action.params[1] + else + 1; + + const mode: kitty.KeySetMode = switch (number) { + 0 => .set, + 1 => .@"or", + 2 => .not, + else => { + log.warn("invalid setKittyKeyboard command: {}", .{action}); + break :set; + }, + }; + + try self.handler.setKittyKeyboard( + mode, + @bitCast(flags), + ); + }, + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI u: {}", + .{action}, + ), + }, + + // ICH - Insert Blanks + '@' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "insertBlanks")) switch (action.params.len) { + 0 => try self.handler.insertBlanks(1), + 1 => try self.handler.insertBlanks(action.params[0]), + else => log.warn("invalid ICH command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + else => log.warn( + "ignoring unimplemented CSI @: {}", + .{action}, + ), + }, + + // DECSASD - Select Active Status Display + '}' => { + const success = decsasd: { + // Verify we're getting a DECSASD command + if (action.intermediates.len != 1 or action.intermediates[0] != '$') + break :decsasd false; + if (action.params.len != 1) + break :decsasd false; + if (!@hasDecl(T, "setActiveStatusDisplay")) + break :decsasd false; + + try self.handler.setActiveStatusDisplay(@enumFromInt(action.params[0])); + break :decsasd true; + }; + + if (!success) log.warn("unimplemented CSI callback: {}", .{action}); + }, + + else => if (@hasDecl(T, "csiUnimplemented")) + try self.handler.csiUnimplemented(action) + else + log.warn("unimplemented CSI action: {}", .{action}), + } + } + + fn oscDispatch(self: *Self, cmd: osc.Command) !void { + switch (cmd) { + .change_window_title => |title| { + if (@hasDecl(T, "changeWindowTitle")) { + if (!std.unicode.utf8ValidateSlice(title)) { + log.warn("change title request: invalid utf-8, ignoring request", .{}); + return; + } + + try self.handler.changeWindowTitle(title); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .change_window_icon => |icon| { + log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); + }, + + .clipboard_contents => |clip| { + if (@hasDecl(T, "clipboardContents")) { + try self.handler.clipboardContents(clip.kind, clip.data); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .prompt_start => |v| { + if (@hasDecl(T, "promptStart")) { + switch (v.kind) { + .primary, .right => try self.handler.promptStart(v.aid, v.redraw), + .continuation => try self.handler.promptContinuation(v.aid), + } + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .prompt_end => { + if (@hasDecl(T, "promptEnd")) { + try self.handler.promptEnd(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .end_of_input => { + if (@hasDecl(T, "endOfInput")) { + try self.handler.endOfInput(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .end_of_command => |end| { + if (@hasDecl(T, "endOfCommand")) { + try self.handler.endOfCommand(end.exit_code); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .report_pwd => |v| { + if (@hasDecl(T, "reportPwd")) { + try self.handler.reportPwd(v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .mouse_shape => |v| { + if (@hasDecl(T, "setMouseShape")) { + const shape = MouseShape.fromString(v.value) orelse { + log.warn("unknown cursor shape: {s}", .{v.value}); + return; + }; + + try self.handler.setMouseShape(shape); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .report_color => |v| { + if (@hasDecl(T, "reportColor")) { + try self.handler.reportColor(v.kind, v.terminator); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .set_color => |v| { + if (@hasDecl(T, "setColor")) { + try self.handler.setColor(v.kind, v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .reset_color => |v| { + if (@hasDecl(T, "resetColor")) { + try self.handler.resetColor(v.kind, v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .show_desktop_notification => |v| { + if (@hasDecl(T, "showDesktopNotification")) { + try self.handler.showDesktopNotification(v.title, v.body); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + } + + // Fall through for when we don't have a handler. + if (@hasDecl(T, "oscUnimplemented")) { + try self.handler.oscUnimplemented(cmd); + } else { + log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); + } + } + + fn configureCharset( + self: *Self, + intermediates: []const u8, + set: charsets.Charset, + ) !void { + if (intermediates.len != 1) { + log.warn("invalid charset intermediate: {any}", .{intermediates}); + return; + } + + const slot: charsets.Slots = switch (intermediates[0]) { + // TODO: support slots '-', '.', '/' + + '(' => .G0, + ')' => .G1, + '*' => .G2, + '+' => .G3, + else => { + log.warn("invalid charset intermediate: {any}", .{intermediates}); + return; + }, + }; + + if (@hasDecl(T, "configureCharset")) { + try self.handler.configureCharset(slot, set); + return; + } + + log.warn("unimplemented configureCharset callback slot={} set={}", .{ + slot, + set, + }); + } + + fn escDispatch( + self: *Self, + action: Parser.Action.ESC, + ) !void { + switch (action.final) { + // Charsets + 'B' => try self.configureCharset(action.intermediates, .ascii), + 'A' => try self.configureCharset(action.intermediates, .british), + '0' => try self.configureCharset(action.intermediates, .dec_special), + + // DECSC - Save Cursor + '7' => if (@hasDecl(T, "saveCursor")) switch (action.intermediates.len) { + 0 => try self.handler.saveCursor(), + else => { + log.warn("invalid command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + '8' => blk: { + switch (action.intermediates.len) { + // DECRC - Restore Cursor + 0 => if (@hasDecl(T, "restoreCursor")) { + try self.handler.restoreCursor(); + break :blk {}; + } else log.warn("unimplemented restore cursor callback: {}", .{action}), + + 1 => switch (action.intermediates[0]) { + // DECALN - Fill Screen with E + '#' => if (@hasDecl(T, "decaln")) { + try self.handler.decaln(); + break :blk {}; + } else log.warn("unimplemented ESC callback: {}", .{action}), + + else => {}, + }, + + else => {}, // fall through + } + + log.warn("unimplemented ESC action: {}", .{action}); + }, + + // IND - Index + 'D' => if (@hasDecl(T, "index")) switch (action.intermediates.len) { + 0 => try self.handler.index(), + else => { + log.warn("invalid index command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // NEL - Next Line + 'E' => if (@hasDecl(T, "nextLine")) switch (action.intermediates.len) { + 0 => try self.handler.nextLine(), + else => { + log.warn("invalid next line command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // HTS - Horizontal Tab Set + 'H' => if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{action}), + + // RI - Reverse Index + 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { + 0 => try self.handler.reverseIndex(), + else => { + log.warn("invalid reverse index command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // SS2 - Single Shift 2 + 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G2, true), + else => { + log.warn("invalid single shift 2 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // SS3 - Single Shift 3 + 'O' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G3, true), + else => { + log.warn("invalid single shift 3 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // DECID + 'Z' => if (@hasDecl(T, "deviceAttributes")) { + try self.handler.deviceAttributes(.primary, &.{}); + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // RIS - Full Reset + 'c' => if (@hasDecl(T, "fullReset")) switch (action.intermediates.len) { + 0 => try self.handler.fullReset(), + else => { + log.warn("invalid full reset command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // LS2 - Locking Shift 2 + 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G2, false), + else => { + log.warn("invalid single shift 2 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS3 - Locking Shift 3 + 'o' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G3, false), + else => { + log.warn("invalid single shift 3 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS1R - Locking Shift 1 Right + '~' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G1, false), + else => { + log.warn("invalid locking shift 1 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS2R - Locking Shift 2 Right + '}' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G2, false), + else => { + log.warn("invalid locking shift 2 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS3R - Locking Shift 3 Right + '|' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G3, false), + else => { + log.warn("invalid locking shift 3 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // Set application keypad mode + '=' => if (@hasDecl(T, "setMode")) { + try self.handler.setMode(.keypad_keys, true); + } else log.warn("unimplemented setMode: {}", .{action}), + + // Reset application keypad mode + '>' => if (@hasDecl(T, "setMode")) { + try self.handler.setMode(.keypad_keys, false); + } else log.warn("unimplemented setMode: {}", .{action}), + + else => if (@hasDecl(T, "escUnimplemented")) + try self.handler.escUnimplemented(action) + else + log.warn("unimplemented ESC action: {}", .{action}), + + // Sets ST (string terminator). We don't have to do anything + // because our parser always accepts ST. + '\\' => {}, + } + } + }; +} + +test "stream: print" { + const H = struct { + c: ?u21 = 0, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.next('x'); + try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); +} + +test "simd: print invalid utf-8" { + const H = struct { + c: ?u21 = 0, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice(&.{0xFF}); + try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); +} + +test "simd: complete incomplete utf-8" { + const H = struct { + c: ?u21 = null, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice(&.{0xE0}); // 3 byte + try testing.expect(s.handler.c == null); + try s.nextSlice(&.{0xA0}); // still incomplete + try testing.expect(s.handler.c == null); + try s.nextSlice(&.{0x80}); + try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); +} + +test "stream: cursor right (CUF)" { + const H = struct { + amount: u16 = 0, + + pub fn setCursorRight(self: *@This(), v: u16) !void { + self.amount = v; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[C"); + try testing.expectEqual(@as(u16, 1), s.handler.amount); + + try s.nextSlice("\x1B[5C"); + try testing.expectEqual(@as(u16, 5), s.handler.amount); + + s.handler.amount = 0; + try s.nextSlice("\x1B[5;4C"); + try testing.expectEqual(@as(u16, 0), s.handler.amount); +} + +test "stream: dec set mode (SM) and reset mode (RM)" { + const H = struct { + mode: modes.Mode = @as(modes.Mode, @enumFromInt(1)), + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = @as(modes.Mode, @enumFromInt(1)); + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[?6h"); + try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); + + try s.nextSlice("\x1B[?6l"); + try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); +} + +test "stream: ansi set mode (SM) and reset mode (RM)" { + const H = struct { + mode: ?modes.Mode = null, + + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = null; + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[4h"); + try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); + + try s.nextSlice("\x1B[4l"); + try testing.expect(s.handler.mode == null); +} + +test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { + const H = struct { + mode: ?modes.Mode = null, + + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = null; + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[6h"); + try testing.expect(s.handler.mode == null); + + try s.nextSlice("\x1B[6l"); + try testing.expect(s.handler.mode == null); +} + +test "stream: restore mode" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn setTopAndBottomMargin(self: *Self, t: u16, b: u16) !void { + _ = t; + _ = b; + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[?42r") |c| try s.next(c); + try testing.expect(!s.handler.called); +} + +test "stream: pop kitty keyboard with no params defaults to 1" { + const H = struct { + const Self = @This(); + n: u16 = 0, + + pub fn popKittyKeyboard(self: *Self, n: u16) !void { + self.n = n; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[2s"); + try testing.expect(s.handler.escape == null); + + try s.nextSlice("\x1B[>s"); + try testing.expect(s.handler.escape.? == false); + + try s.nextSlice("\x1B[>0s"); + try testing.expect(s.handler.escape.? == false); + + try s.nextSlice("\x1B[>1s"); + try testing.expect(s.handler.escape.? == true); +} + +test "stream: change window title with invalid utf-8" { + const H = struct { + seen: bool = false, + + pub fn changeWindowTitle(self: *@This(), title: []const u8) !void { + _ = title; + + self.seen = true; + } + }; + + { + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1b]2;abc\x1b\\"); + try testing.expect(s.handler.seen); + } + + { + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1b]2;abc\xc0\x1b\\"); + try testing.expect(!s.handler.seen); + } +} + +test "stream: insert characters" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn insertBlanks(self: *Self, v: u16) !void { + _ = v; + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[42@") |c| try s.next(c); + try testing.expect(s.handler.called); + + s.handler.called = false; + for ("\x1B[?42@") |c| try s.next(c); + try testing.expect(!s.handler.called); +} + +test "stream: SCOSC" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn setLeftAndRightMargin(self: *Self, left: u16, right: u16) !void { + _ = self; + _ = left; + _ = right; + @panic("bad"); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[s") |c| try s.next(c); + try testing.expect(s.handler.called); +} + +test "stream: SCORC" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn restoreCursor(self: *Self) !void { + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[u") |c| try s.next(c); + try testing.expect(s.handler.called); +} + +test "stream: too many csi params" { + const H = struct { + pub fn setCursorRight(self: *@This(), v: u16) !void { + _ = v; + _ = self; + unreachable; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); +} + +test "stream: csi param too long" { + const H = struct { + pub fn setCursorRight(self: *@This(), v: u16) !void { + _ = v; + _ = self; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); +} From 2f92243df43e8c9077a1ece4956f6df58cbba1ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 21:00:02 -0800 Subject: [PATCH 189/428] terminal2: pagelist cellIterator --- src/terminal2/PageList.zig | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index eeff789a97..0dfc88201c 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1492,6 +1492,37 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { }; } +pub const CellIterator = struct { + row_it: RowIterator, + cell: ?Pin = null, + + pub fn next(self: *CellIterator) ?Pin { + const cell = self.cell orelse return null; + + if (cell.x + 1 < cell.page.data.size.cols) { + // We still have cells in this row, increase x. + var copy = cell; + copy.x += 1; + self.cell = copy; + } else { + // We need to move to the next row. + self.cell = self.row_it.next(); + } + + return cell; + } +}; + +pub fn cellIterator( + self: *const PageList, + tl_pt: point.Point, + bl_pt: ?point.Point, +) CellIterator { + var row_it = self.rowIterator(tl_pt, bl_pt); + const cell = row_it.next() orelse return .{ .row_it = row_it }; + return .{ .row_it = row_it, .cell = cell }; +} + pub const RowIterator = struct { page_it: PageIterator, chunk: ?PageIterator.Chunk = null, @@ -4516,3 +4547,53 @@ test "PageList resize reflow less cols to wrap a wide char" { } } } + +test "PageList cellIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + var it = s.cellIterator(.{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} From 83af8d1aacecc7a34e4cee547adb9d124dea7356 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 21:30:35 -0800 Subject: [PATCH 190/428] terminal2: PageList pageIterator reverse --- src/terminal2/PageList.zig | 364 ++++++++++++++++++++++++++++--------- src/terminal2/Screen.zig | 2 +- src/terminal2/Terminal.zig | 2 +- 3 files changed, 281 insertions(+), 87 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 0dfc88201c..f7c6ecbe24 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -257,7 +257,7 @@ pub fn clone( bot: ?point.Point, ) !PageList { // First, count our pages so our preheat is exactly what we need. - var it = self.pageIterator(top, bot); + var it = self.pageIterator(.right_down, top, bot); const page_count: usize = page_count: { var count: usize = 0; while (it.next()) |_| count += 1; @@ -281,7 +281,7 @@ pub fn clonePool( top: point.Point, bot: ?point.Point, ) !PageList { - var it = self.pageIterator(top, bot); + var it = self.pageIterator(.right_down, top, bot); // Copy our pages var page_list: List = .{}; @@ -427,7 +427,7 @@ fn resizeCols( const cap = try std_capacity.adjust(.{ .cols = cols }); // Go page by page and shrink the columns on a per-page basis. - var it = self.pageIterator(.{ .screen = .{} }, null); + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { // Fast-path: none of our rows are wrapped. In this case we can // treat this like a no-reflow resize. This only applies if we @@ -911,7 +911,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // resize the columns, and clear any cells that are beyond // the new size. .lt => { - var it = self.pageIterator(.{ .screen = .{} }, null); + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { const page = &chunk.page.data; const rows = page.rows.ptr(page.memory); @@ -940,7 +940,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { .gt => { const cap = try std_capacity.adjust(.{ .cols = cols }); - var it = self.pageIterator(.{ .screen = .{} }, null); + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { try self.resizeWithoutReflowGrowCols(cap, chunk); } @@ -1270,7 +1270,7 @@ pub fn eraseRows( // A pageIterator iterates one page at a time from the back forward. // "back" here is in terms of scrollback, but actually the front of the // linked list. - var it = self.pageIterator(tl_pt, bl_pt); + var it = self.pageIterator(.right_down, tl_pt, bl_pt); while (it.next()) |chunk| { // If the chunk is a full page, deinit thit page and remove it from // the linked list. @@ -1492,6 +1492,9 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { }; } +/// Direction that iterators can move. +pub const Direction = enum { left_up, right_down }; + pub const CellIterator = struct { row_it: RowIterator, cell: ?Pin = null, @@ -1555,7 +1558,7 @@ pub fn rowIterator( tl_pt: point.Point, bl_pt: ?point.Point, ) RowIterator { - var page_it = self.pageIterator(tl_pt, bl_pt); + var page_it = self.pageIterator(.right_down, tl_pt, bl_pt); const chunk = page_it.next() orelse return .{ .page_it = page_it }; return .{ .page_it = page_it, .chunk = chunk, .offset = chunk.start }; } @@ -1563,6 +1566,7 @@ pub fn rowIterator( pub const PageIterator = struct { row: ?Pin = null, limit: Limit = .none, + direction: Direction = .right_down, const Limit = union(enum) { none, @@ -1571,6 +1575,13 @@ pub const PageIterator = struct { }; pub fn next(self: *PageIterator) ?Chunk { + return switch (self.direction) { + .left_up => self.nextUp(), + .right_down => self.nextDown(), + }; + } + + fn nextDown(self: *PageIterator) ?Chunk { // Get our current row location const row = self.row orelse return null; @@ -1636,6 +1647,78 @@ pub const PageIterator = struct { }; } + fn nextUp(self: *PageIterator) ?Chunk { + // Get our current row location + const row = self.row orelse return null; + + return switch (self.limit) { + .none => none: { + // If we have no limit, then we consume this entire page. Our + // next row is the next page. + self.row = next: { + const next_page = row.page.prev orelse break :next null; + break :next .{ + .page = next_page, + .y = next_page.data.size.rows - 1, + }; + }; + + break :none .{ + .page = row.page, + .start = 0, + .end = row.y + 1, + }; + }, + + .count => |*limit| count: { + assert(limit.* > 0); // should be handled already + const len = @min(row.y, limit.*); + if (len > limit.*) { + self.row = row.up(len); + limit.* -= len; + } else { + self.row = null; + } + + break :count .{ + .page = row.page, + .start = row.y - len, + .end = row.y - 1, + }; + }, + + .row => |limit_row| row: { + // If this is not the same page as our limit then we + // can consume the entire page. + if (limit_row.page != row.page) { + self.row = next: { + const next_page = row.page.prev orelse break :next null; + break :next .{ + .page = next_page, + .y = next_page.data.size.rows - 1, + }; + }; + + break :row .{ + .page = row.page, + .start = 0, + .end = row.y + 1, + }; + } + + // If this is the same page then we only consume up to + // the limit row. + self.row = null; + if (row.y < limit_row.y) return null; + break :row .{ + .page = row.page, + .start = limit_row.y, + .end = row.y + 1, + }; + }, + }; + } + pub const Chunk = struct { page: *List.Node, start: usize, @@ -1667,37 +1750,33 @@ pub const PageIterator = struct { /// bl_pt must be greater than or equal to tl_pt. pub fn pageIterator( self: *const PageList, + direction: Direction, tl_pt: point.Point, bl_pt: ?point.Point, ) PageIterator { - // TODO: bl_pt assertions - - const tl = self.getTopLeft(tl_pt); - const limit: PageIterator.Limit = limit: { - if (bl_pt) |pt| { - const bl = self.getTopLeft(pt); - break :limit .{ .row = bl.down(pt.coord().y).? }; - } + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse return .{ .row = null }; - break :limit switch (tl_pt) { - // These always go to the end of the screen. - .screen, .active => .{ .none = {} }, + if (comptime std.debug.runtime_safety) { + assert(tl_pin.eql(bl_pin) or tl_pin.isBefore(bl_pin)); + } - // Viewport always is rows long - .viewport => .{ .count = self.rows }, + return switch (direction) { + .right_down => .{ + .row = tl_pin, + .limit = .{ .row = bl_pin }, + .direction = .right_down, + }, - // History goes to the top of the active area. This is more expensive - // to calculate but also more rare of a thing to iterate over. - .history => history: { - const active_tl = self.getTopLeft(.active); - const history_bot = active_tl.up(1) orelse - return .{ .row = null }; - break :history .{ .row = history_bot }; - }, - }; + .left_up => .{ + .row = bl_pin, + .limit = .{ .row = tl_pin }, + .direction = .left_up, + }, }; - - return .{ .row = tl.down(tl_pt.coord().y), .limit = limit }; } /// Get the top-left of the screen for the given tag. @@ -1732,6 +1811,32 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { }; } +/// Returns the bottom right of the screen for the given tag. This can +/// return null because it is possible that a tag is not in the screen +/// (e.g. history does not yet exist). +fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { + return switch (tag) { + .screen, .active => last: { + const page = self.pages.last.?; + break :last .{ + .page = page, + .y = page.data.size.rows - 1, + .x = page.data.size.cols - 1, + }; + }, + + .viewport => viewport: { + const tl = self.getTopLeft(.viewport); + break :viewport tl.down(self.rows - 1).?; + }, + + .history => active: { + const tl = self.getTopLeft(.active); + break :active tl.up(1); + }, + }; +} + /// The total rows in the screen. This is the actual row count currently /// and not a capacity or maximum. /// @@ -2467,7 +2572,7 @@ test "PageList pageIterator single page" { try testing.expect(s.pages.first.?.next == null); // Iterate the active area - var it = s.pageIterator(.{ .active = .{} }, null); + var it = s.pageIterator(.right_down, .{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -2495,7 +2600,7 @@ test "PageList pageIterator two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.pageIterator(.{ .active = .{} }, null); + var it = s.pageIterator(.right_down, .{ .active = .{} }, null); { const chunk = it.next().?; try testing.expect(chunk.page == s.pages.first.?); @@ -2529,7 +2634,7 @@ test "PageList pageIterator history two pages" { try testing.expect(try s.grow() != null); // Iterate the active area - var it = s.pageIterator(.{ .history = .{} }, null); + var it = s.pageIterator(.right_down, .{ .history = .{} }, null); { const active_tl = s.getTopLeft(.active); const chunk = it.next().?; @@ -2541,6 +2646,145 @@ test "PageList pageIterator history two pages" { try testing.expect(it.next() == null); } +test "PageList pageIterator reverse single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // The viewport should be within a single page + try testing.expect(s.pages.first.?.next == null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .active = .{} }, null); + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + try testing.expectEqual(@as(usize, 0), chunk.start); + try testing.expectEqual(@as(usize, s.rows), chunk.end); + } + + // Should only have one chunk + try testing.expect(it.next() == null); +} + +test "PageList pageIterator reverse two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .active = .{} }, null); + var count: usize = 0; + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.last.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(start + 1, chunk.end); + count += chunk.end - chunk.start; + } + { + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start = chunk.page.data.size.rows - s.rows + 1; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(chunk.page.data.size.rows, chunk.end); + count += chunk.end - chunk.start; + } + try testing.expect(it.next() == null); + try testing.expectEqual(s.rows, count); +} + +test "PageList pageIterator reverse history two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to capacity + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + try testing.expect(try s.grow() != null); + + // Iterate the active area + var it = s.pageIterator(.left_up, .{ .history = .{} }, null); + { + const active_tl = s.getTopLeft(.active); + const chunk = it.next().?; + try testing.expect(chunk.page == s.pages.first.?); + const start: usize = 0; + try testing.expectEqual(start, chunk.start); + try testing.expectEqual(active_tl.y, chunk.end); + } + try testing.expect(it.next() == null); +} + +test "PageList cellIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + var it = s.cellIterator(.{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; @@ -3181,7 +3425,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.totalRows()); - var it = s.pageIterator(.{ .screen = .{} }, null); + var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { try testing.expectEqual(@as(usize, 0), chunk.page.data.graphemeCount()); } @@ -4547,53 +4791,3 @@ test "PageList resize reflow less cols to wrap a wide char" { } } } - -test "PageList cellIterator" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x) }, - }; - } - } - - var it = s.cellIterator(.{ .screen = .{} }, null); - { - const p = it.next().?; - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pointFromPin(.screen, p).?); - } - { - const p = it.next().?; - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pointFromPin(.screen, p).?); - } - { - const p = it.next().?; - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 1, - } }, s.pointFromPin(.screen, p).?); - } - { - const p = it.next().?; - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 1, - } }, s.pointFromPin(.screen, p).?); - } - try testing.expect(it.next() == null); -} diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 17fd1301a1..1a2d410fa7 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -503,7 +503,7 @@ pub fn clearRows( bl: ?point.Point, protected: bool, ) void { - var it = self.pages.pageIterator(tl, bl); + var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_offset = row.cells; diff --git a/src/terminal2/Terminal.zig b/src/terminal2/Terminal.zig index 3347fed7e5..94d33f7343 100644 --- a/src/terminal2/Terminal.zig +++ b/src/terminal2/Terminal.zig @@ -1864,7 +1864,7 @@ pub fn decaln(self: *Terminal) !void { self.eraseDisplay(.complete, false); // Fill with Es, does not move cursor. - var it = self.screen.pages.pageIterator(.{ .active = .{} }, null); + 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); From 6dd88c29ca1c7ce917ae87e4e0d50f569c8a1cef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 21:39:22 -0800 Subject: [PATCH 191/428] terminal2: PageList iterators all support reverse --- src/terminal2/PageList.zig | 167 +++++++++++++++++++++++++++++-------- src/terminal2/Screen.zig | 2 +- 2 files changed, 133 insertions(+), 36 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index f7c6ecbe24..b5569d935d 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1502,14 +1502,36 @@ pub const CellIterator = struct { pub fn next(self: *CellIterator) ?Pin { const cell = self.cell orelse return null; - if (cell.x + 1 < cell.page.data.size.cols) { - // We still have cells in this row, increase x. - var copy = cell; - copy.x += 1; - self.cell = copy; - } else { - // We need to move to the next row. - self.cell = self.row_it.next(); + switch (self.row_it.page_it.direction) { + .right_down => { + if (cell.x + 1 < cell.page.data.size.cols) { + // We still have cells in this row, increase x. + var copy = cell; + copy.x += 1; + self.cell = copy; + } else { + // We need to move to the next row. + self.cell = self.row_it.next(); + } + }, + + .left_up => { + if (cell.x > 0) { + // We still have cells in this row, decrease x. + var copy = cell; + copy.x -= 1; + self.cell = copy; + } else { + // We need to move to the previous row and last col + if (self.row_it.next()) |next_cell| { + var copy = next_cell; + copy.x = next_cell.page.data.size.cols - 1; + self.cell = copy; + } else { + self.cell = null; + } + } + }, } return cell; @@ -1518,11 +1540,13 @@ pub const CellIterator = struct { pub fn cellIterator( self: *const PageList, + direction: Direction, tl_pt: point.Point, bl_pt: ?point.Point, ) CellIterator { - var row_it = self.rowIterator(tl_pt, bl_pt); - const cell = row_it.next() orelse return .{ .row_it = row_it }; + var row_it = self.rowIterator(direction, tl_pt, bl_pt); + var cell = row_it.next() orelse return .{ .row_it = row_it }; + if (direction == .left_up) cell.x = cell.page.data.size.cols - 1; return .{ .row_it = row_it, .cell = cell }; } @@ -1535,13 +1559,28 @@ pub const RowIterator = struct { const chunk = self.chunk orelse return null; const row: Pin = .{ .page = chunk.page, .y = self.offset }; - // Increase our offset in the chunk - self.offset += 1; + switch (self.page_it.direction) { + .right_down => { + // Increase our offset in the chunk + self.offset += 1; + + // If we are beyond the chunk end, we need to move to the next chunk. + if (self.offset >= chunk.end) { + self.chunk = self.page_it.next(); + if (self.chunk) |c| self.offset = c.start; + } + }, - // If we are beyond the chunk end, we need to move to the next chunk. - if (self.offset >= chunk.end) { - self.chunk = self.page_it.next(); - if (self.chunk) |c| self.offset = c.start; + .left_up => { + // If we are at the start of the chunk, we need to move to the + // previous chunk. + if (self.offset == 0) { + self.chunk = self.page_it.next(); + if (self.chunk) |c| self.offset = c.end - 1; + } else { + self.offset -= 1; + } + }, } return row; @@ -1555,12 +1594,20 @@ pub const RowIterator = struct { /// iteration bounds. pub fn rowIterator( self: *const PageList, + direction: Direction, tl_pt: point.Point, bl_pt: ?point.Point, ) RowIterator { - var page_it = self.pageIterator(.right_down, tl_pt, bl_pt); + var page_it = self.pageIterator(direction, tl_pt, bl_pt); const chunk = page_it.next() orelse return .{ .page_it = page_it }; - return .{ .page_it = page_it, .chunk = chunk, .offset = chunk.start }; + return .{ + .page_it = page_it, + .chunk = chunk, + .offset = switch (direction) { + .right_down => chunk.start, + .left_up => chunk.end - 1, + }, + }; } pub const PageIterator = struct { @@ -2753,7 +2800,7 @@ test "PageList cellIterator" { } } - var it = s.cellIterator(.{ .screen = .{} }, null); + var it = s.cellIterator(.right_down, .{ .screen = .{} }, null); { const p = it.next().?; try testing.expectEqual(point.Point{ .screen = .{ @@ -2785,6 +2832,56 @@ test "PageList cellIterator" { try testing.expect(it.next() == null); } +test "PageList cellIterator reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + var it = s.cellIterator(.left_up, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; @@ -3364,7 +3461,7 @@ test "PageList resize (no reflow) less cols" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3388,7 +3485,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 10), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3443,7 +3540,7 @@ test "PageList resize (no reflow) more cols" { try testing.expectEqual(@as(usize, 10), s.cols); try testing.expectEqual(@as(usize, 3), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3467,7 +3564,7 @@ test "PageList resize (no reflow) less cols then more cols" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 3), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3487,7 +3584,7 @@ test "PageList resize (no reflow) less rows and cols" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 7), s.rows); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3508,7 +3605,7 @@ test "PageList resize (no reflow) more rows and less cols" { try testing.expectEqual(@as(usize, 20), s.rows); try testing.expectEqual(@as(usize, 20), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3529,7 +3626,7 @@ test "PageList resize (no reflow) empty screen" { try testing.expectEqual(@as(usize, 10), s.rows); try testing.expectEqual(@as(usize, 10), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3568,7 +3665,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { // Our total rows should be the same, and contents should be the same. try testing.expectEqual(rows, s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3673,7 +3770,7 @@ test "PageList resize reflow more cols no wrapped rows" { try testing.expectEqual(@as(usize, 10), s.cols); try testing.expectEqual(@as(usize, 3), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); @@ -3722,7 +3819,7 @@ test "PageList resize reflow more cols wrapped rows" { } }, pt); } - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be unwrapped const offset = it.next().?; @@ -4199,7 +4296,7 @@ test "PageList resize reflow less cols no wrapped rows" { try testing.expectEqual(@as(usize, 5), s.cols); try testing.expectEqual(@as(usize, 3), s.totalRows()); - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { for (0..4) |x| { var offset_copy = offset; @@ -4244,7 +4341,7 @@ test "PageList resize reflow less cols wrapped rows" { } }, pt); } - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; @@ -4320,7 +4417,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; - var it = s.rowIterator(.{ .screen = .{} }, null); + var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; @@ -4554,7 +4651,7 @@ test "PageList resize reflow less cols blank lines" { try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 3), s.totalRows()); - var it = s.rowIterator(.{ .active = .{} }, null); + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); { // First row should be wrapped const offset = it.next().?; @@ -4606,7 +4703,7 @@ test "PageList resize reflow less cols blank lines between" { try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 4), s.totalRows()); - var it = s.rowIterator(.{ .active = .{} }, null); + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); { const offset = it.next().?; const rac = offset.rowAndCell(); @@ -4661,7 +4758,7 @@ test "PageList resize reflow less cols copy style" { try testing.expectEqual(@as(usize, 2), s.cols); try testing.expectEqual(@as(usize, 2), s.totalRows()); - var it = s.rowIterator(.{ .active = .{} }, null); + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); while (it.next()) |offset| { for (0..s.cols - 1) |x| { var offset_copy = offset; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 1a2d410fa7..38a04a8f81 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -852,7 +852,7 @@ pub fn dumpString( ) !void { var blank_rows: usize = 0; - var iter = self.pages.rowIterator(tl, null); + var iter = self.pages.rowIterator(.right_down, tl, null); while (iter.next()) |row_offset| { const rac = row_offset.rowAndCell(); const cells = cells: { From 1f01b2c4c94b03866bd41f8f80418a77026187de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 22:10:34 -0800 Subject: [PATCH 192/428] terminal2: selection adjust right --- src/terminal2/PageList.zig | 9 ++- src/terminal2/Selection.zig | 151 +++++++++++++++++++----------------- 2 files changed, 88 insertions(+), 72 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index b5569d935d..5327e49ea1 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1546,7 +1546,14 @@ pub fn cellIterator( ) CellIterator { var row_it = self.rowIterator(direction, tl_pt, bl_pt); var cell = row_it.next() orelse return .{ .row_it = row_it }; - if (direction == .left_up) cell.x = cell.page.data.size.cols - 1; + cell.x = switch (direction) { + .right_down => tl_pt.coord().x, + .left_up => if (bl_pt) |pt| + pt.coord().x + else + cell.page.data.size.cols - 1, + }; + return .{ .row_it = row_it, .cell = cell }; } diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 4401776041..4f6fd8b0ee 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -120,9 +120,6 @@ pub fn adjust( s: *const Screen, adjustment: Adjustment, ) void { - _ = self; - _ = s; - //const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; // Note that we always adjusts "end" because end always represents @@ -162,28 +159,24 @@ pub fn adjust( // } // }, - // .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; - // } - // } - // }, - // + .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, self.end.*).?, + null, + ); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + self.end.* = next; + break; + } + } + }, + // .page_up => if (screen.rows > result.end.y) { // result.end.y = 0; // result.end.x = 0; @@ -218,52 +211,68 @@ test "Selection: adjust right" { defer s.deinit(); try s.testWriteString("A1234\nB5678\nC1234\nD5678"); - // // Simple movement right - // { - // var sel = try Selection.init( - // &s, - // 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. - // { - // 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); - // } + // Simple movement right + { + var sel = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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: order, standard" { From 8194cb7edb83539489d2a55ad9a8ba6f4c89507e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 22:18:52 -0800 Subject: [PATCH 193/428] terminal2: sel adjust left --- src/terminal/Selection.zig | 2 + src/terminal2/Selection.zig | 86 +++++++++++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 6dc2c77ed4..0d7eef1332 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -330,6 +330,7 @@ pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selectio return result; } +// X test "Selection: adjust right" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); @@ -376,6 +377,7 @@ test "Selection: adjust right" { } } +// X test "Selection: adjust left" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 4f6fd8b0ee..69f0ce3a9e 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -139,25 +139,22 @@ pub fn adjust( // } 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; - // } - // } - // }, + + .left => { + var it = s.pages.cellIterator( + .left_up, + .{ .screen = .{} }, + s.pages.pointFromPin(.screen, self.end.*).?, + ); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + self.end.* = next; + break; + } + } + }, .right => { // Step right, wrapping to the next row down at the start of each new line, @@ -275,6 +272,57 @@ test "Selection: adjust right" { } } +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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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: order, standard" { const testing = std.testing; const alloc = testing.allocator; From a303b7628e45312daf206861fd162d66e6c7aba2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 22:31:06 -0800 Subject: [PATCH 194/428] terminal2: adjust down --- src/terminal/Selection.zig | 2 + src/terminal2/Selection.zig | 183 +++++++++++++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 0d7eef1332..bd7553aa96 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -413,6 +413,7 @@ test "Selection: adjust left" { } } +// X test "Selection: adjust left skips blanks" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); @@ -448,6 +449,7 @@ test "Selection: adjust left skips blanks" { } } +// X test "Selection: adjust up" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 69f0ce3a9e..a35dbfca9c 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -3,6 +3,7 @@ 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"); @@ -127,18 +128,27 @@ pub fn adjust( // 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; - // }, + .up => if (self.end.up(1)) |new_end| { + self.end.* = new_end; + } else { + self.end.x = 0; + }, + + .down => { + // Find the next non-blank row + var current = self.end.*; + while (current.down(1)) |next| : (current = next) { + const rac = next.rowAndCell(); + const cells = next.page.data.getCells(rac.row); + if (page.Cell.hasTextAny(cells)) { + self.end.* = next; + break; + } + } else { + // If we're at the bottom, just go to the end of the line + self.end.x = self.end.page.data.size.cols - 1; + } + }, .left => { var it = s.pages.cellIterator( @@ -323,6 +333,155 @@ test "Selection: adjust left" { } } +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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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: order, standard" { const testing = std.testing; const alloc = testing.allocator; From 5c04ebe3bdc739e4b0d8e1727da0c3d678b72c3f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 22:32:13 -0800 Subject: [PATCH 195/428] terminal2: adjust down edges --- src/terminal/Selection.zig | 2 ++ src/terminal2/Selection.zig | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index bd7553aa96..398875b0e5 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -485,6 +485,7 @@ test "Selection: adjust up" { } } +// X test "Selection: adjust down" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); @@ -520,6 +521,7 @@ test "Selection: adjust down" { } } +// X test "Selection: adjust down with not full screen" { const testing = std.testing; var screen = try Screen.init(testing.allocator, 5, 10, 0); diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index a35dbfca9c..184d894681 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -482,6 +482,35 @@ test "Selection: adjust down" { } } +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 = try Selection.init( + &s, + 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: order, standard" { const testing = std.testing; const alloc = testing.allocator; From 7afe2e1ecaf2619be8483ed0186c10c024cd98f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 09:11:54 -0800 Subject: [PATCH 196/428] terminal2: sel adjust home/end --- src/terminal2/Selection.zig | 90 ++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 184d894681..926036687d 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -197,16 +197,28 @@ pub fn adjust( // } 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; - //}, + + .home => self.end.* = 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)) { + self.end.* = next; + self.end.x = cells.len - 1; + break; + } + } + }, else => @panic("TODO"), } @@ -511,6 +523,64 @@ test "Selection: adjust down with not full screen" { } } +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 = try Selection.init( + &s, + 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 = try Selection.init( + &s, + 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; From 3f59f51d40d64c4cb4f585ac366be1e8a5ef023f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 09:16:19 -0800 Subject: [PATCH 197/428] terminal2: selection adjust done --- src/terminal2/Selection.zig | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 926036687d..1ae7a96a0f 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -184,19 +184,18 @@ pub fn adjust( } }, - // .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; - // }, + .page_up => if (self.end.up(s.pages.rows)) |new_end| { + self.end.* = new_end; + } else { + self.adjust(s, .home); + }, + + // TODO(paged-terminal): this doesn't take into account blanks + .page_down => if (self.end.down(s.pages.rows)) |new_end| { + self.end.* = new_end; + } else { + self.adjust(s, .end); + }, .home => self.end.* = s.pages.pin(.{ .screen = .{ .x = 0, @@ -219,8 +218,6 @@ pub fn adjust( } } }, - - else => @panic("TODO"), } } From 3cc2b958035b57f1cfb9e59aaf7529fb503d2c33 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 09:19:09 -0800 Subject: [PATCH 198/428] terminal2: promote Selection --- src/terminal2/main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 2d813c02ba..1045fae7a1 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -29,6 +29,7 @@ pub const Page = page.Page; pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); 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; @@ -52,5 +53,4 @@ test { _ = @import("hash_map.zig"); _ = @import("size.zig"); _ = @import("style.zig"); - _ = @import("Selection.zig"); } From 0494caf6bdab85c6fbd4c422f6b27440f4c9e2ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 09:36:23 -0800 Subject: [PATCH 199/428] terminal2: a selection can be tracked or untracked --- src/terminal2/Selection.zig | 300 +++++++++++++++++++++--------------- 1 file changed, 172 insertions(+), 128 deletions(-) diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 1ae7a96a0f..4d983c4267 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -19,38 +19,48 @@ const Pin = PageList.Pin; // depended on this behavior so I kept it despite the inefficiency. In the // future, we should take a look at this again! -/// 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. -/// -/// These are always tracked pins so that they automatically update as -/// the screen they're attached to gets scrolled, erased, etc. -start: *Pin, -end: *Pin, +/// 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( - s: *Screen, - start: Pin, - end: Pin, + start_pin: Pin, + end_pin: Pin, rect: bool, -) !Selection { - // Track our pins - const tracked_start = try s.pages.trackPin(start); - errdefer s.pages.untrackPin(tracked_start); - const tracked_end = try s.pages.trackPin(end); - errdefer s.pages.untrackPin(tracked_end); - +) Selection { return .{ - .start = tracked_start, - .end = tracked_end, + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, .rectangle = rect, }; } @@ -59,8 +69,71 @@ pub fn deinit( self: Selection, s: *Screen, ) void { - s.pages.untrackPin(self.start); - s.pages.untrackPin(self.end); + switch (self.bounds) { + .tracked => |v| { + s.pages.untrackPin(v.start); + s.pages.untrackPin(v.end); + }, + + .untracked => {}, + } +} + +/// The starting pin of the selection. This is NOT ordered. +pub fn start(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 end(self: *Selection) *Pin { + return switch (self.bounds) { + .untracked => |*v| &v.end, + .tracked => |v| v.end, + }; +} + +fn startConst(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.start, + .tracked => |v| v.start.*, + }; +} + +fn endConst(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, + } }; } /// The order of the selection: @@ -78,8 +151,8 @@ pub fn deinit( 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; + const start_pt = s.pages.pointFromPin(.screen, self.startConst()).?.screen; + const end_pt = s.pages.pointFromPin(.screen, self.endConst()).?.screen; if (self.rectangle) { // Reverse (also handles single-column) @@ -121,32 +194,31 @@ pub fn adjust( s: *const Screen, adjustment: Adjustment, ) void { - //const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; - // 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.end(); switch (adjustment) { - .up => if (self.end.up(1)) |new_end| { - self.end.* = new_end; + .up => if (end_pin.up(1)) |new_end| { + end_pin.* = new_end; } else { - self.end.x = 0; + end_pin.x = 0; }, .down => { // Find the next non-blank row - var current = self.end.*; + 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)) { - self.end.* = next; + end_pin.* = next; break; } } else { // If we're at the bottom, just go to the end of the line - self.end.x = self.end.page.data.size.cols - 1; + end_pin.x = end_pin.page.data.size.cols - 1; } }, @@ -154,13 +226,13 @@ pub fn adjust( var it = s.pages.cellIterator( .left_up, .{ .screen = .{} }, - s.pages.pointFromPin(.screen, self.end.*).?, + s.pages.pointFromPin(.screen, end_pin.*).?, ); _ = it.next(); while (it.next()) |next| { const rac = next.rowAndCell(); if (rac.cell.hasText()) { - self.end.* = next; + end_pin.* = next; break; } } @@ -171,33 +243,33 @@ pub fn adjust( // until we find a non-empty cell. var it = s.pages.cellIterator( .right_down, - s.pages.pointFromPin(.screen, self.end.*).?, + s.pages.pointFromPin(.screen, end_pin.*).?, null, ); _ = it.next(); while (it.next()) |next| { const rac = next.rowAndCell(); if (rac.cell.hasText()) { - self.end.* = next; + end_pin.* = next; break; } } }, - .page_up => if (self.end.up(s.pages.rows)) |new_end| { - self.end.* = new_end; + .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 (self.end.down(s.pages.rows)) |new_end| { - self.end.* = new_end; + .page_down => if (end_pin.down(s.pages.rows)) |new_end| { + end_pin.* = new_end; } else { self.adjust(s, .end); }, - .home => self.end.* = s.pages.pin(.{ .screen = .{ + .home => end_pin.* = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0, } }).?, @@ -212,8 +284,8 @@ pub fn adjust( const rac = next.rowAndCell(); const cells = next.page.data.getCells(rac.row); if (page.Cell.hasTextAny(cells)) { - self.end.* = next; - self.end.x = cells.len - 1; + end_pin.* = next; + end_pin.x = cells.len - 1; break; } } @@ -229,8 +301,7 @@ test "Selection: adjust right" { // Simple movement right { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, false, @@ -241,17 +312,16 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // Already at end of the line. { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 4, .y = 2 } }).?, false, @@ -262,17 +332,16 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // Already at end of the screen { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, false, @@ -283,11 +352,11 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -299,8 +368,7 @@ test "Selection: adjust left" { // Simple movement left { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, false, @@ -312,17 +380,16 @@ test "Selection: adjust left" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // Already at beginning of the line. { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, false, @@ -334,11 +401,11 @@ test "Selection: adjust left" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -350,8 +417,7 @@ test "Selection: adjust left skips blanks" { // Same line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, false, @@ -363,17 +429,16 @@ test "Selection: adjust left skips blanks" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // Edge { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, false, @@ -385,11 +450,11 @@ test "Selection: adjust left skips blanks" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -401,8 +466,7 @@ test "Selection: adjust up" { // Not on the first line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, false, @@ -413,17 +477,16 @@ test "Selection: adjust up" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // On the first line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, false, @@ -434,11 +497,11 @@ test "Selection: adjust up" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -450,8 +513,7 @@ test "Selection: adjust down" { // Not on the first line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, false, @@ -462,17 +524,16 @@ test "Selection: adjust down" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } // On the last line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 4 } }).?, false, @@ -483,11 +544,11 @@ test "Selection: adjust down" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -499,8 +560,7 @@ test "Selection: adjust down with not full screen" { // On the last line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, false, @@ -512,11 +572,11 @@ test "Selection: adjust down with not full screen" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -528,8 +588,7 @@ test "Selection: adjust home" { // On the last line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, false, @@ -541,11 +600,11 @@ test "Selection: adjust home" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -557,8 +616,7 @@ test "Selection: adjust end with not full screen" { // On the last line { - var sel = try Selection.init( - &s, + var sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, false, @@ -570,11 +628,11 @@ test "Selection: adjust end with not full screen" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start.*).?); + } }, s.pages.pointFromPin(.screen, sel.start().*).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end.*).?); + } }, s.pages.pointFromPin(.screen, sel.end().*).?); } } @@ -587,8 +645,7 @@ test "Selection: order, standard" { { // forward, multi-line - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, @@ -599,8 +656,7 @@ test "Selection: order, standard" { } { // reverse, multi-line - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, false, @@ -611,8 +667,7 @@ test "Selection: order, standard" { } { // forward, same-line - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, false, @@ -623,8 +678,7 @@ test "Selection: order, standard" { } { // forward, single char - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, false, @@ -635,8 +689,7 @@ test "Selection: order, standard" { } { // reverse, single line - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, false, @@ -661,8 +714,7 @@ test "Selection: order, rectangle" { // BR - bottom right { // forward (TL -> BR) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, true, @@ -673,8 +725,7 @@ test "Selection: order, rectangle" { } { // reverse (BR -> TL) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, true, @@ -685,8 +736,7 @@ test "Selection: order, rectangle" { } { // mirrored_forward (TR -> BL) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, true, @@ -697,8 +747,7 @@ test "Selection: order, rectangle" { } { // mirrored_reverse (BL -> TR) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, true, @@ -709,8 +758,7 @@ test "Selection: order, rectangle" { } { // forward, single line (left -> right ) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, true, @@ -721,8 +769,7 @@ test "Selection: order, rectangle" { } { // reverse, single line (right -> left) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, true, @@ -733,8 +780,7 @@ test "Selection: order, rectangle" { } { // forward, single column (top -> bottom) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, true, @@ -745,8 +791,7 @@ test "Selection: order, rectangle" { } { // reverse, single column (bottom -> top) - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, true, @@ -757,8 +802,7 @@ test "Selection: order, rectangle" { } { // forward, single cell - const sel = try Selection.init( - &s, + const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, true, From 3b55af31d855899447714a72359a5c8d118ff7a5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 13:18:48 -0800 Subject: [PATCH 200/428] terminal2: Pin iterators --- src/terminal2/PageList.zig | 101 ++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 5327e49ea1..217dae9fe4 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1544,17 +1544,17 @@ pub fn cellIterator( tl_pt: point.Point, bl_pt: ?point.Point, ) CellIterator { - var row_it = self.rowIterator(direction, tl_pt, bl_pt); - var cell = row_it.next() orelse return .{ .row_it = row_it }; - cell.x = switch (direction) { - .right_down => tl_pt.coord().x, - .left_up => if (bl_pt) |pt| - pt.coord().x - else - cell.page.data.size.cols - 1, - }; + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse + return .{ .row_it = undefined }; - return .{ .row_it = row_it, .cell = cell }; + return switch (direction) { + .right_down => tl_pin.cellIterator(.right_down, bl_pin), + .left_up => bl_pin.cellIterator(.left_up, tl_pin), + }; } pub const RowIterator = struct { @@ -1605,15 +1605,16 @@ pub fn rowIterator( tl_pt: point.Point, bl_pt: ?point.Point, ) RowIterator { - var page_it = self.pageIterator(direction, tl_pt, bl_pt); - const chunk = page_it.next() orelse return .{ .page_it = page_it }; - return .{ - .page_it = page_it, - .chunk = chunk, - .offset = switch (direction) { - .right_down => chunk.start, - .left_up => chunk.end - 1, - }, + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse + return .{ .page_it = undefined }; + + return switch (direction) { + .right_down => tl_pin.rowIterator(.right_down, bl_pin), + .left_up => bl_pin.rowIterator(.left_up, tl_pin), }; } @@ -1802,6 +1803,10 @@ pub const PageIterator = struct { /// (inclusive). If bl_pt is null, the entire region specified by the point /// tag will be iterated over. tl_pt and bl_pt must be the same tag, and /// bl_pt must be greater than or equal to tl_pt. +/// +/// If direction is left_up, iteration will go from bl_pt to tl_pt. If +/// direction is right_down, iteration will go from tl_pt to bl_pt. +/// Both inclusive. pub fn pageIterator( self: *const PageList, direction: Direction, @@ -1819,17 +1824,8 @@ pub fn pageIterator( } return switch (direction) { - .right_down => .{ - .row = tl_pin, - .limit = .{ .row = bl_pin }, - .direction = .right_down, - }, - - .left_up => .{ - .row = bl_pin, - .limit = .{ .row = tl_pin }, - .direction = .left_up, - }, + .right_down => tl_pin.pageIterator(.right_down, bl_pin), + .left_up => bl_pin.pageIterator(.left_up, tl_pin), }; } @@ -1955,6 +1951,51 @@ pub const Pin = struct { return .{ .row = rac.row, .cell = rac.cell }; } + /// Iterators. These are the same as PageList iterator funcs but operate + /// on pins rather than points. This is MUCH more efficient than calling + /// pointFromPin and building up the iterator from points. + /// + /// The limit pin is inclusive. + pub fn pageIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) PageIterator { + return .{ + .row = self, + .limit = if (limit) |p| .{ .row = p } else .{ .none = {} }, + .direction = direction, + }; + } + + pub fn rowIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) RowIterator { + var page_it = self.pageIterator(direction, limit); + const chunk = page_it.next() orelse return .{ .page_it = page_it }; + return .{ + .page_it = page_it, + .chunk = chunk, + .offset = switch (direction) { + .right_down => chunk.start, + .left_up => chunk.end - 1, + }, + }; + } + + pub fn cellIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) CellIterator { + var row_it = self.rowIterator(direction, limit); + var cell = row_it.next() orelse return .{ .row_it = row_it }; + cell.x = self.x; + return .{ .row_it = row_it, .cell = cell }; + } + /// Returns true if this pin is between the top and bottom, inclusive. // // Note: this is primarily unit tested as part of the Kitty From 7ffefc9487864f6988fa84b7613a26bc9c0bcc6a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 13:38:52 -0800 Subject: [PATCH 201/428] terminal2: selectLine --- src/terminal2/Screen.zig | 197 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 1 deletion(-) diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 38a04a8f81..baeed55be7 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -8,7 +8,7 @@ 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 Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); @@ -17,6 +17,7 @@ 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. @@ -842,6 +843,119 @@ pub fn manualStyleUpdate(self: *Screen) !void { // @panic("TODO"); // } +/// 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); + while (it.next()) |p| { + const row = p.rowAndCell().row; + + if (!row.wrap) { + var copy = p; + 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 prev = p.down(1).?; + prev.x = 0; + break :start_pin prev; + } + } + + return null; + }; + + // 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); +} + /// 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. @@ -3447,3 +3561,84 @@ test "Screen: resize more cols requiring a wide spacer head" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } + +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().*).?); + } +} From f91624ab6102e8af118d4260a40d28fea551e464 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 13:54:03 -0800 Subject: [PATCH 202/428] terminal2: selectAll --- src/terminal/Screen.zig | 4 ++ src/terminal2/Screen.zig | 126 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8bad89ba97..b13b5b249b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4475,6 +4475,7 @@ test "Screen: clone one line active with extra space" { try testing.expectEqual(@as(usize, 1), s.rowsWritten()); } +// X test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; @@ -4523,6 +4524,8 @@ test "Screen: selectLine" { try testing.expectEqual(@as(usize, 0), sel.end.y); } } + +// X test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; @@ -4549,6 +4552,7 @@ test "Screen: selectAll" { } } +// X test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index baeed55be7..af8bec5fc1 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -866,7 +866,9 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // 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| { + it_prev = p; const row = p.rowAndCell().row; if (!row.wrap) { @@ -882,9 +884,11 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { prev.x = 0; break :start_pin prev; } + } else { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; } - - return null; }; // The real end of the row is the final row in the soft-wrap. @@ -956,6 +960,62 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { 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); +} + /// 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. @@ -3642,3 +3702,65 @@ test "Screen: selectLine" { } }, 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: 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().*).?); + } +} From 201ad4d8506f3e750c57c85fe5b47d5ff05710ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 14:01:24 -0800 Subject: [PATCH 203/428] terminal2: more selectLine tests --- src/terminal/Screen.zig | 2 + src/terminal2/Screen.zig | 103 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b13b5b249b..4132c9539a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4606,6 +4606,7 @@ test "Screen: selectLine semantic prompt boundary" { } } +// X test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; @@ -4642,6 +4643,7 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { } } +// X test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index af8bec5fc1..37b6dee456 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -3729,6 +3729,66 @@ test "Screen: selectLine across soft-wrap" { } } +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: selectAll" { const testing = std.testing; const alloc = testing.allocator; @@ -3764,3 +3824,46 @@ test "Screen: selectAll" { } }, 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().*).?); + } +} From d97f8618e3933c308f350a8779e590ef27695d07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 16:55:33 -0800 Subject: [PATCH 204/428] terminal2: selectLine fixes --- src/terminal/Screen.zig | 1 + src/terminal2/Screen.zig | 129 ++++++++++++++++++++++++++++----------- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4132c9539a..25007e72eb 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4571,6 +4571,7 @@ test "Screen: selectLine across soft-wrap" { } } +// X // https://github.com/mitchellh/ghostty/issues/1329 test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 37b6dee456..d814741495 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -868,11 +868,10 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { var it = pin.rowIterator(.left_up, null); var it_prev: Pin = pin; while (it.next()) |p| { - it_prev = p; const row = p.rowAndCell().row; if (!row.wrap) { - var copy = p; + var copy = it_prev; copy.x = 0; break :start_pin copy; } @@ -880,10 +879,12 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // See semantic_prompt_state comment for why const current_prompt = row.semantic_prompt.promptOrInput(); if (current_prompt != semantic_prompt_state) { - var prev = p.down(1).?; - prev.x = 0; - break :start_pin prev; + var copy = it_prev; + copy.x = 0; + break :start_pin copy; } + + it_prev = p; } else { var copy = it_prev; copy.x = 0; @@ -3622,6 +3623,42 @@ test "Screen: resize more cols requiring a wide spacer head" { } } +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; @@ -3789,71 +3826,89 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { } } -test "Screen: selectAll" { +test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, 2, 3, 5); defer s.deinit(); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + // Selecting first line { - try s.testWriteString("ABC DEF\n 123\n456"); - var sel = s.selectAll().?; + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ + try testing.expectEqual(point.Point{ .active = .{ .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().*).?); + } }, 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 { - try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); - var sel = s.selectAll().?; + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 2, + } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ + try testing.expectEqual(point.Point{ .active = .{ .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().*).?); + .y = 2, + } }, s.pages.pointFromPin(.active, sel.start().*).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.end().*).?); } } -test "Screen: selectLine with scrollback" { +// 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, 2, 3, 5); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + try s.testWriteString("ABCDE\nA > "); - // Selecting first line + { + 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 = 0, - .y = 0, + .x = 1, + .y = 1, } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, - .y = 0, + .y = 1, } }, s.pages.pointFromPin(.active, sel.start().*).?); try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, + .x = 0, + .y = 1, } }, s.pages.pointFromPin(.active, sel.end().*).?); } - - // Selecting last line { var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 0, + .x = 1, .y = 2, } }).?).?; defer sel.deinit(&s); @@ -3862,7 +3917,7 @@ test "Screen: selectLine with scrollback" { .y = 2, } }, s.pages.pointFromPin(.active, sel.start().*).?); try testing.expectEqual(point.Point{ .active = .{ - .x = 1, + .x = 0, .y = 2, } }, s.pages.pointFromPin(.active, sel.end().*).?); } From 56fc4d7a1e3aa4154c391012f39c4e497c659e42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 17:15:28 -0800 Subject: [PATCH 205/428] terminal2: selectWord starts --- src/terminal/Screen.zig | 1 + src/terminal2/Screen.zig | 223 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 25007e72eb..e8f5482770 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4672,6 +4672,7 @@ test "Screen: selectLine with scrollback" { } } +// X test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index d814741495..e08df0b9b6 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -1017,6 +1017,114 @@ pub fn selectAll(self: *Screen) ?Selection { 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 are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == 0 and !rac.row.wrap) { + break :end prev; + } + + // 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; + + 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); +} + /// 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. @@ -3922,3 +4030,118 @@ test "Screen: selectLine semantic prompt boundary" { } }, 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().*).?); + } +} From f03b9f95e08c0f19ee5110722e20fa98f24f8794 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 17:20:26 -0800 Subject: [PATCH 206/428] terminal2: selectWord more tests --- src/terminal/Screen.zig | 1 + src/terminal2/Screen.zig | 78 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e8f5482770..e0f5803e48 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4740,6 +4740,7 @@ test "Screen: selectWord" { } } +// X test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index e08df0b9b6..eb30a04d90 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -1068,12 +1068,6 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { 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 == 0 and !rac.row.wrap) { - break :end prev; - } - // If we reached an empty cell its always a boundary if (!cell.hasText()) break :end prev; @@ -1085,6 +1079,12 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { ) != 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; } @@ -4145,3 +4145,69 @@ test "Screen: selectWord" { } }, 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().*).?); + } +} From d9d3aa318541092928e4e8f03f518d8444ce5811 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 17:25:11 -0800 Subject: [PATCH 207/428] terminal2: selectWord done --- src/terminal/Screen.zig | 2 + src/terminal2/Screen.zig | 159 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e0f5803e48..5638bff312 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4777,6 +4777,7 @@ test "Screen: selectWord across soft-wrap" { } } +// X test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; @@ -4813,6 +4814,7 @@ test "Screen: selectWord whitespace across soft-wrap" { } } +// X test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index eb30a04d90..aa85c0bce3 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -4211,3 +4211,162 @@ test "Screen: selectWord across soft-wrap" { } }, 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().*).?); + } + } +} From 48f0724c96139ec7f4c02d216d2ee36766eff035 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 20:57:37 -0800 Subject: [PATCH 208/428] terminal2: selectOutput --- src/terminal/Screen.zig | 1 + src/terminal2/Screen.zig | 198 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5638bff312..20a1277df1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4882,6 +4882,7 @@ test "Screen: selectWord with character boundary" { } } +// X test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index aa85c0bce3..538fadcaf7 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -1125,6 +1125,83 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { 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); +} + /// 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. @@ -4370,3 +4447,124 @@ test "Screen: selectWord with character boundary" { } } } + +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); + } +} From a5d23a0007d29f630c1e5a3bc9ab2f80378abfcf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:08:03 -0800 Subject: [PATCH 209/428] terminal2: selectPrompt --- src/terminal/Screen.zig | 5 +- src/terminal2/Screen.zig | 296 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 20a1277df1..a49e6aed7f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -4956,6 +4956,7 @@ test "Screen: selectOutput" { } } +// X test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; @@ -5019,6 +5020,7 @@ test "Screen: selectPrompt basics" { } } +// X test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; @@ -5059,6 +5061,7 @@ test "Screen: selectPrompt prompt at start" { } } +// X test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; @@ -5097,7 +5100,7 @@ test "Screen: selectPrompt prompt at end" { } } -test "Screen: promtpPath" { +test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 538fadcaf7..c3e279686e 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -1202,6 +1202,87 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { 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); +} + /// 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. @@ -4568,3 +4649,218 @@ test "Screen: selectOutput" { } }).?) == 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().*).?); + } +} From 90e96e0cc57a629195251b8edb8adc5a5784d5a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:14:33 -0800 Subject: [PATCH 210/428] terminal2: selection start/endPTr --- src/terminal2/Screen.zig | 148 ++++++++++++++++++------------------ src/terminal2/Selection.zig | 70 ++++++++--------- 2 files changed, 109 insertions(+), 109 deletions(-) diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index c3e279686e..7c5df9899c 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -3903,11 +3903,11 @@ test "Screen: selectAll" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } { @@ -3917,11 +3917,11 @@ test "Screen: selectAll" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 8, .y = 7, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -3947,11 +3947,11 @@ test "Screen: selectLine" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward @@ -3964,11 +3964,11 @@ test "Screen: selectLine" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward @@ -3981,11 +3981,11 @@ test "Screen: selectLine" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Outside active area @@ -3998,11 +3998,11 @@ test "Screen: selectLine" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4024,11 +4024,11 @@ test "Screen: selectLine across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4050,11 +4050,11 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward @@ -4067,11 +4067,11 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward @@ -4084,11 +4084,11 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4110,11 +4110,11 @@ test "Screen: selectLine with scrollback" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } // Selecting last line @@ -4127,11 +4127,11 @@ test "Screen: selectLine with scrollback" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 2, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } } @@ -4166,11 +4166,11 @@ test "Screen: selectLine semantic prompt boundary" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } { var sel = s.selectLine(s.pages.pin(.{ .active = .{ @@ -4181,11 +4181,11 @@ test "Screen: selectLine semantic prompt boundary" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } } @@ -4211,11 +4211,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward @@ -4228,11 +4228,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward @@ -4245,11 +4245,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Whitespace @@ -4262,11 +4262,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Whitespace single char @@ -4279,11 +4279,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // End of screen @@ -4296,11 +4296,11 @@ test "Screen: selectWord" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4328,11 +4328,11 @@ test "Screen: selectWord across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward @@ -4345,11 +4345,11 @@ test "Screen: selectWord across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward @@ -4362,11 +4362,11 @@ test "Screen: selectWord across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4388,11 +4388,11 @@ test "Screen: selectWord whitespace across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward @@ -4405,11 +4405,11 @@ test "Screen: selectWord whitespace across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward @@ -4422,11 +4422,11 @@ test "Screen: selectWord whitespace across soft-wrap" { try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4467,11 +4467,11 @@ test "Screen: selectWord with character boundary" { try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Inside character backward @@ -4484,11 +4484,11 @@ test "Screen: selectWord with character boundary" { try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Inside character bidirectional @@ -4501,11 +4501,11 @@ test "Screen: selectWord with character boundary" { try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On quote @@ -4520,11 +4520,11 @@ test "Screen: selectWord with character boundary" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } } @@ -4588,11 +4588,11 @@ test "Screen: selectOutput" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, .y = 1, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } // Both start and end markers, should select between them { @@ -4604,11 +4604,11 @@ test "Screen: selectOutput" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 4, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, .y = 5, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } // No end marker, should select till the end { @@ -4620,11 +4620,11 @@ test "Screen: selectOutput" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 7, - } }, s.pages.pointFromPin(.active, sel.start().*).?); + } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, .y = 10, - } }, s.pages.pointFromPin(.active, sel.end().*).?); + } }, s.pages.pointFromPin(.active, sel.end()).?); } // input / prompt at y = 0, pt.y = 0 { @@ -4725,11 +4725,11 @@ test "Screen: selectPrompt basics" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 6, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 9, .y = 6, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Multi line prompt @@ -4742,11 +4742,11 @@ test "Screen: selectPrompt basics" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 9, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4802,11 +4802,11 @@ test "Screen: selectPrompt prompt at start" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 9, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -4857,10 +4857,10 @@ test "Screen: selectPrompt prompt at end" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 9, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 4d983c4267..435d42fd64 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -80,7 +80,7 @@ pub fn deinit( } /// The starting pin of the selection. This is NOT ordered. -pub fn start(self: *Selection) *Pin { +pub fn startPtr(self: *Selection) *Pin { return switch (self.bounds) { .untracked => |*v| &v.start, .tracked => |v| v.start, @@ -88,21 +88,21 @@ pub fn start(self: *Selection) *Pin { } /// The ending pin of the selection. This is NOT ordered. -pub fn end(self: *Selection) *Pin { +pub fn endPtr(self: *Selection) *Pin { return switch (self.bounds) { .untracked => |*v| &v.end, .tracked => |v| v.end, }; } -fn startConst(self: Selection) Pin { +pub fn start(self: Selection) Pin { return switch (self.bounds) { .untracked => |v| v.start, .tracked => |v| v.start.*, }; } -fn endConst(self: Selection) Pin { +pub fn end(self: Selection) Pin { return switch (self.bounds) { .untracked => |v| v.end, .tracked => |v| v.end.*, @@ -151,8 +151,8 @@ pub fn track(self: *Selection, s: *Screen) !void { 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.startConst()).?.screen; - const end_pt = s.pages.pointFromPin(.screen, self.endConst()).?.screen; + 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) @@ -198,7 +198,7 @@ pub fn adjust( // 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.end(); + const end_pin = self.endPtr(); switch (adjustment) { .up => if (end_pin.up(1)) |new_end| { end_pin.* = new_end; @@ -312,11 +312,11 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the line. @@ -332,11 +332,11 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the screen @@ -352,11 +352,11 @@ test "Selection: adjust right" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -380,11 +380,11 @@ test "Selection: adjust left" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at beginning of the line. @@ -401,11 +401,11 @@ test "Selection: adjust left" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -429,11 +429,11 @@ test "Selection: adjust left skips blanks" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Edge @@ -450,11 +450,11 @@ test "Selection: adjust left skips blanks" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -477,11 +477,11 @@ test "Selection: adjust up" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the first line @@ -497,11 +497,11 @@ test "Selection: adjust up" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -524,11 +524,11 @@ test "Selection: adjust down" { try testing.expectEqual(point.Point{ .screen = .{ .x = 5, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the last line @@ -544,11 +544,11 @@ test "Selection: adjust down" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -572,11 +572,11 @@ test "Selection: adjust down with not full screen" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -600,11 +600,11 @@ test "Selection: adjust home" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } @@ -628,11 +628,11 @@ test "Selection: adjust end with not full screen" { try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start().*).?); + } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end().*).?); + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } From 5fc4a9c8e3a1bb0721ef01cd19871cba10095586 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:25:23 -0800 Subject: [PATCH 211/428] terminal2: selection topLeft/bottomRight --- src/terminal/Selection.zig | 2 + src/terminal2/Selection.zig | 162 ++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 398875b0e5..bcb2aa5923 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -915,6 +915,7 @@ test "Selection: order, rectangle" { } } +// X test "topLeft" { const testing = std.testing; { @@ -957,6 +958,7 @@ test "topLeft" { } } +// X test "bottomRight" { const testing = std.testing; { diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index 435d42fd64..e7c2c570af 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -136,6 +136,42 @@ pub fn track(self: *Selection, s: *Screen) !void { } }; } +/// Returns the top left point of the selection. +pub fn topLeft(self: Selection, s: *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: *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). @@ -812,3 +848,129 @@ test "Selection: order, rectangle" { 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)); + } +} From 0b3c502268ffdc20408b85d7a377fe8e08c86a4a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:35:36 -0800 Subject: [PATCH 212/428] terminal2: Selection.ordered --- src/terminal/Selection.zig | 1 + src/terminal2/Selection.zig | 114 +++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index bcb2aa5923..6bb7c049af 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -1001,6 +1001,7 @@ test "bottomRight" { } } +// X test "ordered" { const testing = std.testing; { diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index e7c2c570af..c681e9263d 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -79,6 +79,13 @@ pub fn deinit( } } +/// 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) { @@ -137,7 +144,7 @@ pub fn track(self: *Selection, s: *Screen) !void { } /// Returns the top left point of the selection. -pub fn topLeft(self: Selection, s: *Screen) Pin { +pub fn topLeft(self: Selection, s: *const Screen) Pin { return switch (self.order(s)) { .forward => self.start(), .reverse => self.end(), @@ -155,7 +162,7 @@ pub fn topLeft(self: Selection, s: *Screen) Pin { } /// Returns the bottom right point of the selection. -pub fn bottomRight(self: Selection, s: *Screen) Pin { +pub fn bottomRight(self: Selection, s: *const Screen) Pin { return switch (self.order(s)) { .forward => self.end(), .reverse => self.start(), @@ -211,6 +218,28 @@ pub fn order(self: Selection, s: *const Screen) Order { 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), + }; +} + /// Possible adjustments to the selection. pub const Adjustment = enum { left, @@ -974,3 +1003,84 @@ test "bottomRight" { } }, 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)); + } +} From 9f78ec597af5732aae81442f032d6e78c4d438ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:44:32 -0800 Subject: [PATCH 213/428] terminal2: contains selection --- src/terminal/Selection.zig | 2 + src/terminal2/Selection.zig | 144 ++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 6bb7c049af..d29513d73e 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -543,6 +543,7 @@ test "Selection: adjust down with not full screen" { } } +// X test "Selection: contains" { const testing = std.testing; { @@ -586,6 +587,7 @@ test "Selection: contains" { } } +// X test "Selection: contains, rectangle" { const testing = std.testing; { diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig index c681e9263d..a404cf0e52 100644 --- a/src/terminal2/Selection.zig +++ b/src/terminal2/Selection.zig @@ -240,6 +240,41 @@ pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { }; } +/// 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, @@ -1084,3 +1119,112 @@ test "ordered" { 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 } }).?)); + } +} From 7ee6447191e5dc8f62a242f3078629dd1e2393e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 21:49:30 -0800 Subject: [PATCH 214/428] terminal2: promptPath --- src/terminal/Screen.zig | 1 + src/terminal2/PageList.zig | 4 +- src/terminal2/Screen.zig | 137 +++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a49e6aed7f..fb89a0087e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5100,6 +5100,7 @@ test "Screen: selectPrompt prompt at end" { } } +// X test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 217dae9fe4..45b493785e 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1820,7 +1820,7 @@ pub fn pageIterator( self.getBottomRight(tl_pt) orelse return .{ .row = null }; if (comptime std.debug.runtime_safety) { - assert(tl_pin.eql(bl_pin) or tl_pin.isBefore(bl_pin)); + assert(tl_pin.eql(bl_pin) or tl_pin.before(bl_pin)); } return switch (direction) { @@ -2045,7 +2045,7 @@ pub const Pin = struct { /// Returns true if self is before other. This is very expensive since /// it requires traversing the linked list of pages. This should not /// be called in performance critical paths. - pub fn isBefore(self: Pin, other: Pin) bool { + pub fn before(self: Pin, other: Pin) bool { if (self.page == other.page) { if (self.y < other.y) return true; if (self.y > other.y) return false; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 7c5df9899c..36ab792cdb 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -1283,6 +1283,43 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { 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. @@ -4864,3 +4901,103 @@ test "Screen: selectPrompt prompt at end" { } }, 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); + } +} From 44986a0dccbb71b69fb4bb7bae8a20e0ebf29dad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 22:10:58 -0800 Subject: [PATCH 215/428] terminal2: selectionString beginning --- src/terminal/Screen.zig | 4 + src/terminal2/Screen.zig | 187 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fb89a0087e..06d19b84d6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5543,6 +5543,7 @@ test "Screen: clear above cursor with history" { try testing.expectEqual(@as(usize, 0), s.cursor.y); } +// X test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; @@ -5563,6 +5564,7 @@ test "Screen: selectionString basic" { } } +// X test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; @@ -5583,6 +5585,7 @@ test "Screen: selectionString start outside of written area" { } } +// X test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; @@ -5603,6 +5606,7 @@ test "Screen: selectionString end outside of written area" { } } +// X test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 36ab792cdb..7889a7b543 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -830,18 +830,83 @@ pub fn manualStyleUpdate(self: *Screen) !void { /// 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 { -// _ = self; -// _ = alloc; -// _ = sel; -// _ = trim; -// @panic("TODO"); -// } +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); + 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_ordered.start().x + else + 0; + const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) + sel_ordered.end().x + 1 + else + self.pages.cols; + + const cells = cells_ptr[start_x..end_x]; + for (cells) |cell| { + if (!cell.hasText()) continue; + const char = if (cell.content.codepoint > 0) cell.content.codepoint else ' '; + + var buf: [4]u8 = undefined; + const encode_len = try std.unicode.utf8Encode(char, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + } + // TODO: graphemes + + if (!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); + 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; +} /// 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 @@ -5001,3 +5066,101 @@ test "Screen: promptPath" { 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 + // TODO(paged-terminal): we need to trim unwritten space + // { + // const contents = try s.selectionString(alloc, sel, false); + // defer alloc.free(contents); + // const expected = "1AB \n2EF"; + // try testing.expectEqualStrings(expected, contents); + // } +} From 3c7c2c68582309f8e5f618dc532bb25a607c4142 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Mar 2024 22:24:57 -0800 Subject: [PATCH 216/428] terminal2: selectionString more tests --- src/terminal/Screen.zig | 4 ++ src/terminal2/Screen.zig | 137 +++++++++++++++++++++++++++++++++++---- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 06d19b84d6..553b970044 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5638,6 +5638,7 @@ test "Screen: selectionString trim space" { } } +// X test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; @@ -5669,6 +5670,7 @@ test "Screen: selectionString trim empty line" { } } +// X test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; @@ -5689,6 +5691,7 @@ test "Screen: selectionString soft wrap" { } } +// X - can't happen in new terminal test "Screen: selectionString wrap around" { const testing = std.testing; const alloc = testing.allocator; @@ -5715,6 +5718,7 @@ test "Screen: selectionString wrap around" { } } +// X test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 7889a7b543..2f1e018ec8 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -843,7 +843,20 @@ pub fn selectionString( defer strbuilder.deinit(); const sel_ordered = sel.ordered(self, .forward); - var page_it = sel.start().pageIterator(.right_down, sel.end()); + 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; + if (cell.wide == .spacer_tail) end.x -= 1; + 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(); @@ -851,11 +864,11 @@ pub fn selectionString( const cells_ptr = row.cells.ptr(chunk.page.data.memory); const start_x = if (row_count == 0 or sel_ordered.rectangle) - sel_ordered.start().x + sel_start.x else 0; const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) - sel_ordered.end().x + 1 + sel_end.x + 1 else self.pages.cols; @@ -870,7 +883,9 @@ pub fn selectionString( } // TODO: graphemes - if (!row.wrap or sel_ordered.rectangle) { + if (row_count < rows.len - 1 and + (!row.wrap or sel_ordered.rectangle)) + { try strbuilder.append('\n'); } @@ -894,7 +909,7 @@ pub fn selectionString( const i = strbuilder.items.len; strbuilder.items.len += trimmed.len; std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - strbuilder.appendAssumeCapacity('\n'); + try strbuilder.append('\n'); } // Remove our trailing newline again @@ -5156,11 +5171,109 @@ test "Screen: selectionString trim space" { } // No trim - // TODO(paged-terminal): we need to trim unwritten space - // { - // const contents = try s.selectionString(alloc, sel, false); - // defer alloc.free(contents); - // const expected = "1AB \n2EF"; - // try testing.expectEqualStrings(expected, contents); - // } + { + 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); + } } From 01ceb7b2675c92e004bb169687700a56713801fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 13:26:42 -0800 Subject: [PATCH 217/428] terminal2: selectionString with wide spacer head --- src/terminal/Screen.zig | 2 ++ src/terminal2/Screen.zig | 66 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 553b970044..107cefb345 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5759,6 +5759,7 @@ test "Screen: selectionString wide char" { } } +// X test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; @@ -5779,6 +5780,7 @@ test "Screen: selectionString wide char with header" { } } +// X // https://github.com/mitchellh/ghostty/issues/289 test "Screen: selectionString empty with soft wrap" { const testing = std.testing; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 2f1e018ec8..3e3255e77e 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -852,7 +852,18 @@ pub fn selectionString( const sel_end = end: { var end = sel.end(); const cell = end.rowAndCell().cell; - if (cell.wide == .spacer_tail) end.x -= 1; + 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; }; @@ -5277,3 +5288,56 @@ test "Screen: selectionString wide char" { 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); + } +} From 016db4386779260140512c5725fd943fe8a66758 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 13:37:27 -0800 Subject: [PATCH 218/428] terminal2: zwjs in selectionString --- src/terminal/Screen.zig | 1 + src/terminal2/Screen.zig | 70 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 107cefb345..42b17f6fe7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5810,6 +5810,7 @@ test "Screen: selectionString empty with soft wrap" { } } +// X test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 3e3255e77e..48aab90211 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -884,15 +884,23 @@ pub fn selectionString( self.pages.cols; const cells = cells_ptr[start_x..end_x]; - for (cells) |cell| { + for (cells) |*cell| { if (!cell.hasText()) continue; - const char = if (cell.content.codepoint > 0) cell.content.codepoint else ' '; var buf: [4]u8 = undefined; - const encode_len = try std.unicode.utf8Encode(char, &buf); - try strbuilder.appendSlice(buf[0..encode_len]); + { + const char = if (cell.content.codepoint > 0) cell.content.codepoint 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]); + } + } } - // TODO: graphemes if (row_count < rows.len - 1 and (!row.wrap or sel_ordered.rectangle)) @@ -1508,7 +1516,24 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); if (width == 0) { - @panic("zero-width todo"); + 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) { @@ -5341,3 +5366,36 @@ test "Screen: selectionString empty with soft wrap" { 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); + } +} From 0b2b56506a6a6996e36059bd65fb1d489fd2649c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 13:40:07 -0800 Subject: [PATCH 219/428] terminal2: selectionString with rect --- src/terminal/Screen.zig | 3 ++ src/terminal2/Screen.zig | 98 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 42b17f6fe7..ae58eef295 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -5847,6 +5847,7 @@ test "Screen: selectionString with zero width joiner" { } } +// X test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; @@ -5877,6 +5878,7 @@ 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; @@ -5909,6 +5911,7 @@ 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; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 48aab90211..366d8e348e 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -5399,3 +5399,101 @@ test "Screen: selectionString with zero width joiner" { 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); +// } From 17cfdc0487e4ce9c2a85414fcd778d8d94ea7b00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 13:47:17 -0800 Subject: [PATCH 220/428] terminal2: better blank line handling --- src/terminal2/Screen.zig | 88 ++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 366d8e348e..5292a4d5bd 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -885,11 +885,16 @@ pub fn selectionString( const cells = cells_ptr[start_x..end_x]; for (cells) |*cell| { - if (!cell.hasText()) continue; + // Skip wide spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } var buf: [4]u8 = undefined; { - const char = if (cell.content.codepoint > 0) cell.content.codepoint else ' '; + 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]); } @@ -931,8 +936,11 @@ pub fn selectionString( try strbuilder.append('\n'); } - // Remove our trailing newline again - if (strbuilder.items.len > 0) strbuilder.items.len -= 1; + // 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 @@ -5221,7 +5229,7 @@ test "Screen: selectionString trim empty line" { var s = try init(alloc, 5, 5, 0); defer s.deinit(); - const str = "1AB \n \n2EFGH\n3IJKL"; + const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); const sel = Selection.init( @@ -5462,38 +5470,38 @@ test "Screen: selectionString, rectangle, w/EOL" { 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); -// } +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); +} From 7fd85bd177c1b08cab504de91a341987b73130c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 20:22:01 -0800 Subject: [PATCH 221/428] terminal2: resize cols blank row preservation --- src/terminal/Screen.zig | 1 + src/terminal2/PageList.zig | 149 ++++++++++++++++++++++++++++++++++--- src/terminal2/Screen.zig | 29 ++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ae58eef295..385ce1eba1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -7454,6 +7454,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { 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; diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 45b493785e..83aa99c834 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -398,7 +398,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { .gt => { // We grow rows after cols so that we can do our unwrapping/reflow // before we do a no-reflow grow. - try self.resizeCols(cols); + try self.resizeCols(cols, opts.cursor); try self.resizeWithoutReflow(opts); }, @@ -411,7 +411,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { break :opts copy; }); - try self.resizeCols(cols); + try self.resizeCols(cols, opts.cursor); }, } } @@ -420,12 +420,35 @@ pub fn resize(self: *PageList, opts: Resize) !void { fn resizeCols( self: *PageList, cols: size.CellCountInt, + cursor: ?Resize.Cursor, ) !void { assert(cols != self.cols); // Our new capacity, ensure we can fit the cols const cap = try std_capacity.adjust(.{ .cols = cols }); + // If we have a cursor position (x,y), then we try under any col resizing + // to keep the same number remaining active rows beneath it. This is a + // very special case if you can imagine clearing the screen (i.e. + // scrollClear), having an empty active area, and then resizing to less + // cols then we don't want the active area to "jump" to the bottom and + // pull down scrollback. + const preserved_cursor: ?struct { + tracked_pin: *Pin, + remaining_rows: usize, + } = if (cursor) |c| cursor: { + const p = self.pin(.{ .active = .{ + .x = c.x, + .y = c.y, + } }) orelse break :cursor null; + + break :cursor .{ + .tracked_pin = try self.trackPin(p), + .remaining_rows = self.rows - c.y - 1, + }; + } else null; + defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin); + // Go page by page and shrink the columns on a per-page basis. var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { @@ -463,6 +486,39 @@ fn resizeCols( for (total..self.rows) |_| _ = try self.grow(); } + // See preserved_cursor setup for why. + if (preserved_cursor) |c| cursor: { + const active_pt = self.pointFromPin( + .active, + c.tracked_pin.*, + ) orelse break :cursor; + + // We need to determine how many rows we wrapped from the original + // and subtract that from the remaining rows we expect because if + // we wrap down we don't want to push our original row contents into + // the scrollback. + const wrapped = wrapped: { + var wrapped: usize = 0; + + var row_it = c.tracked_pin.rowIterator(.left_up, null); + _ = row_it.next(); // skip ourselves + while (row_it.next()) |next| { + const row = next.rowAndCell().row; + if (!row.wrap) break; + wrapped += 1; + } + + break :wrapped wrapped; + }; + + // If we wrapped more than we expect, do nothing. + if (wrapped >= c.remaining_rows) break :cursor; + const desired = c.remaining_rows - wrapped; + const current = self.rows - (active_pt.active.y + 1); + if (current >= desired) break :cursor; + for (0..desired - current) |_| _ = try self.grow(); + } + // Update our cols self.cols = cols; } @@ -628,17 +684,48 @@ fn reflowPage( // row is wrapped then we don't trim trailing empty cells because // the empty cells can be meaningful. const trailing_empty = src_cursor.countTrailingEmptyCells(); - const cols_len = src_cursor.page.size.cols - trailing_empty; + const cols_len = cols_len: { + var cols_len = src_cursor.page.size.cols - trailing_empty; + if (cols_len > 0) break :cols_len cols_len; + + // If a tracked pin is in this row then we need to keep it + // even if it is empty, because it is somehow meaningful + // (usually the screen cursor), but we do trim the cells + // down to the desired size. + // + // The reason we do this logic is because if you do a scroll + // clear (i.e. move all active into scrollback and reset + // the screen), the cursor is on the top line again with + // an empty active. If you resize to a smaller col size we + // don't want to "pull down" all the scrollback again. The + // user expects we just shrink the active area. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y) continue; - if (cols_len == 0) { - // If the row is empty, we don't copy it. We count it as a - // blank line and continue to the next row. - blank_lines += 1; - continue; - } + // If our tracked pin is outside our resized cols, we + // trim it to the last col, we don't want to wrap blanks. + if (p.x >= cap.cols) p.x = cap.cols - 1; + + // We increase our col len to at least include this pin + cols_len = @max(cols_len, p.x + 1); + } + + if (cols_len == 0) { + // If the row is empty, we don't copy it. We count it as a + // blank line and continue to the next row. + blank_lines += 1; + continue; + } + + break :cols_len cols_len; + }; // We have data, if we have blank lines we need to create them first. for (0..blank_lines) |_| { + // TODO: cursor in here dst_cursor.cursorScroll(); } @@ -4775,6 +4862,50 @@ test "PageList resize reflow less cols blank lines between" { } } +test "PageList resize reflow less cols cursor not on last line preserves location" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 1); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Grow blank rows to push our rows back into scrollback + try s.growRows(5); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ + .cols = 4, + .reflow = true, + + // Important: not on last row + .cursor = .{ .x = 1, .y = 1 }, + }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + test "PageList resize reflow less cols copy style" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 5292a4d5bd..694d5dfc0e 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -3698,6 +3698,35 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { } } +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; From 6b364f81c030877671123317df3c10ebcfc6616b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 20:24:38 -0800 Subject: [PATCH 222/428] terminal: todo for paged-terminal --- src/terminal/main.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 5c2a64e207..51261d8d47 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -24,11 +24,10 @@ pub const CharsetActiveSlot = charsets.ActiveSlot; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; -pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); pub const Screen = @import("Screen.zig"); -pub const StringMap = @import("StringMap.zig"); +pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; pub const CursorStyleReq = ansi.CursorStyle; @@ -43,6 +42,9 @@ 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"); From a972a885ce6a8b64dd5ee84552b2d73cb069d44a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 20:30:53 -0800 Subject: [PATCH 223/428] terminal: remove new import --- src/terminal/main.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 51261d8d47..5f29ce70c7 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -50,9 +50,6 @@ 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()); } From 0e62076f5281fe63717d567fc467f33a5f6b60c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 20:33:26 -0800 Subject: [PATCH 224/428] Revert "terminal: remove new import" This reverts commit 7dbac298ff834ec927186891eed91974042e970d. --- src/terminal/main.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 5f29ce70c7..51261d8d47 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -50,6 +50,9 @@ 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()); } From 312eb050f3b645d2214eac276158bda290e60da8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Mar 2024 21:31:16 -0800 Subject: [PATCH 225/428] terminal2: add Pin.cells --- src/terminal2/PageList.zig | 15 +++++++++++++++ src/terminal2/main.zig | 1 + 2 files changed, 16 insertions(+) diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index 83aa99c834..aae4c1c9ea 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -2038,6 +2038,21 @@ pub const Pin = struct { return .{ .row = rac.row, .cell = rac.cell }; } + pub const CellSubset = enum { all, left, right }; + + /// Returns the cells for the row that this pin is on. The subset determines + /// what subset of the cells are returned. The "left/right" subsets are + /// inclusive of the x coordinate of the pin. + pub fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { + const rac = self.rowAndCell(); + const all = self.page.data.getCells(rac.row); + return switch (subset) { + .all => all, + .left => all[0 .. self.x + 1], + .right => all[self.x..], + }; + } + /// Iterators. These are the same as PageList iterator funcs but operate /// on pins rather than points. This is MUCH more efficient than calling /// pointFromPin and building up the iterator from points. diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 1045fae7a1..8945f4ea5e 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -28,6 +28,7 @@ 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 Terminal = @import("Terminal.zig"); From e3230cf1e6c6519c0d6a6588d3f2844fb02ca05f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 09:28:22 -0800 Subject: [PATCH 226/428] font/shaper: start converting run to new terminal --- src/font/shaper/harfbuzz.zig | 865 +++++++++++++++++++---------------- src/font/shaper/run.zig | 111 +++-- src/terminal2/PageList.zig | 6 + src/terminal2/main.zig | 1 + src/terminal2/page.zig | 16 +- 5 files changed, 541 insertions(+), 458 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 601b642fe2..cf1d9c8903 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"); +const terminal = @import("../../terminal/main.zig").new; const log = std.log.scoped(.font_shaper); @@ -84,7 +84,7 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, group: *GroupCache, - row: terminal.Screen.Row, + row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, ) font.shape.RunIterator { @@ -242,13 +242,18 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("ABCD"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -256,12 +261,17 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -269,13 +279,18 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("A😃D"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |_| { count += 1; @@ -287,71 +302,71 @@ test "run iterator" { } } -test "run iterator: empty cells with background set" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - { - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 5, 0); - defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = try terminal.color.Name.cyan.default() }; - try screen.testWriteString("A"); - - // Get our first row - const row = screen.getRow(.{ .active = 0 }); - row.getCellPtr(1).* = screen.cursor.pen; - row.getCellPtr(2).* = screen.cursor.pen; - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - // The run should have length 3 because of the two background - // cells. - try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); - } - try testing.expectEqual(@as(usize, 1), count); - } -} - -test "shape" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - 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 - - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString(buf[0..buf_idx]); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); -} +// test "run iterator: empty cells with background set" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// { +// // Make a screen with some data +// var screen = try terminal.Screen.init(alloc, 3, 5, 0); +// defer screen.deinit(); +// screen.cursor.pen.bg = .{ .rgb = try terminal.color.Name.cyan.default() }; +// try screen.testWriteString("A"); +// +// // Get our first row +// const row = screen.getRow(.{ .active = 0 }); +// row.getCellPtr(1).* = screen.cursor.pen; +// row.getCellPtr(2).* = screen.cursor.pen; +// +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// +// // The run should have length 3 because of the two background +// // cells. +// try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); +// const cells = try shaper.shape(run); +// try testing.expectEqual(@as(usize, 3), cells.len); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// } +// +// test "shape" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// 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 +// +// // Make a screen with some data +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString(buf[0..buf_idx]); +// +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } test "shape inconsolata ligs" { const testing = std.testing; @@ -361,12 +376,17 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -380,12 +400,17 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -408,12 +433,17 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -436,12 +466,17 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 3, 5, 0); + var screen = try terminal.Screen.init(alloc, 5, 3, 0); defer screen.deinit(); try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -469,13 +504,18 @@ test "shape emoji width long" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -502,13 +542,18 @@ test "shape variation selector VS15" { buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -533,13 +578,18 @@ test "shape variation selector VS16" { buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -559,23 +609,28 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString("A"); - screen.cursor.x += 5; + screen.cursorRight(5); try screen.testWriteString("B"); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), count); try testing.expectEqual(@as(usize, 7), cells.len); } - try testing.expectEqual(@as(usize, 1), count); } test "shape Chinese characters" { @@ -593,13 +648,18 @@ test "shape Chinese characters" { buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 30, 0); + var screen = try terminal.Screen.init(alloc, 30, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -634,13 +694,18 @@ test "shape box glyphs" { buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, 10, 3, 0); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); + var it = shaper.runIterator( + testdata.cache, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -655,311 +720,311 @@ test "shape box glyphs" { try testing.expectEqual(@as(usize, 1), count); } -test "shape selection boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); - - // Full line selection - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = screen.cols - 1, .y = 0 }, - }, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } - - // Offset x, goes to end of line selection - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 2, .y = 0 }, - .end = .{ .x = screen.cols - 1, .y = 0 }, - }, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Offset x, starts at beginning of line - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Selection only subset of line - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 3), count); - } - - // Selection only one character - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 1, .y = 0 }, - }, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 3), count); - } -} - -test "shape cursor boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString("a1b2c3d4e5"); - - // No cursor is full line - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } - - // Cursor at index 0 is two runs - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Cursor at index 1 is three runs - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 3), count); - } - - // Cursor at last col is two runs - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } -} - -test "shape cursor boundary and colored emoji" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString("👍🏼"); - - // No cursor is full line - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } - - // Cursor on emoji does not split it - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } - { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } -} - -test "shape cell attribute change" { - const testing = std.testing; - const alloc = testing.allocator; - - var testdata = try testShaper(alloc); - defer testdata.deinit(); - - // Plain >= should shape into 1 run - { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString(">="); - - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } - - // Bold vs regular should split - { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - try screen.testWriteString(">"); - screen.cursor.pen.attrs.bold = true; - try screen.testWriteString("="); - - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Changing fg color should split - { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - screen.cursor.pen.fg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; - try screen.testWriteString(">"); - screen.cursor.pen.fg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; - try screen.testWriteString("="); - - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Changing bg color should split - { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; - try screen.testWriteString(">"); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; - try screen.testWriteString("="); - - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 2), count); - } - - // Same bg color should not split - { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(); - screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; - try screen.testWriteString(">"); - try screen.testWriteString("="); - - var shaper = &testdata.shaper; - var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); - } - try testing.expectEqual(@as(usize, 1), count); - } -} +// test "shape selection boundary" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// // Make a screen with some data +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString("a1b2c3d4e5"); +// +// // Full line selection +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ +// .start = .{ .x = 0, .y = 0 }, +// .end = .{ .x = screen.cols - 1, .y = 0 }, +// }, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// +// // Offset x, goes to end of line selection +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ +// .start = .{ .x = 2, .y = 0 }, +// .end = .{ .x = screen.cols - 1, .y = 0 }, +// }, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Offset x, starts at beginning of line +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ +// .start = .{ .x = 0, .y = 0 }, +// .end = .{ .x = 3, .y = 0 }, +// }, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Selection only subset of line +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ +// .start = .{ .x = 1, .y = 0 }, +// .end = .{ .x = 3, .y = 0 }, +// }, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 3), count); +// } +// +// // Selection only one character +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ +// .start = .{ .x = 1, .y = 0 }, +// .end = .{ .x = 1, .y = 0 }, +// }, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 3), count); +// } +// } +// +// test "shape cursor boundary" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// // Make a screen with some data +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString("a1b2c3d4e5"); +// +// // No cursor is full line +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// +// // Cursor at index 0 is two runs +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Cursor at index 1 is three runs +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 3), count); +// } +// +// // Cursor at last col is two runs +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// } +// +// test "shape cursor boundary and colored emoji" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// // Make a screen with some data +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString("👍🏼"); +// +// // No cursor is full line +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// +// // Cursor on emoji does not split it +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// { +// // Get our run iterator +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// } +// +// test "shape cell attribute change" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var testdata = try testShaper(alloc); +// defer testdata.deinit(); +// +// // Plain >= should shape into 1 run +// { +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString(">="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// +// // Bold vs regular should split +// { +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// try screen.testWriteString(">"); +// screen.cursor.pen.attrs.bold = true; +// try screen.testWriteString("="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Changing fg color should split +// { +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// screen.cursor.pen.fg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; +// try screen.testWriteString(">"); +// screen.cursor.pen.fg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; +// try screen.testWriteString("="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Changing bg color should split +// { +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; +// try screen.testWriteString(">"); +// screen.cursor.pen.bg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; +// try screen.testWriteString("="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 2), count); +// } +// +// // Same bg color should not split +// { +// var screen = try terminal.Screen.init(alloc, 3, 10, 0); +// defer screen.deinit(); +// screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; +// try screen.testWriteString(">"); +// try screen.testWriteString("="); +// +// var shaper = &testdata.shaper; +// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); +// var count: usize = 0; +// while (try it.next(alloc)) |run| { +// count += 1; +// _ = try shaper.shape(run); +// } +// try testing.expectEqual(@as(usize, 1), count); +// } +// } const TestShaper = struct { alloc: Allocator, diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 7b75b574d6..3ef900138e 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"); +const terminal = @import("../../terminal/main.zig").new; /// 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 @@ -26,17 +26,22 @@ pub const TextRun = struct { pub const RunIterator = struct { hooks: font.Shaper.RunIteratorHook, group: *font.GroupCache, - row: terminal.Screen.Row, + row: terminal.Pin, selection: ?terminal.Selection = null, cursor_x: ?usize = null, i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { + const cells = self.row.cells(.all); + // Trim the right side of a row that might be empty const max: usize = max: { - var j: usize = self.row.lenCells(); - while (j > 0) : (j -= 1) if (!self.row.getCell(j - 1).empty()) break; - break :max j; + for (0..cells.len) |i| { + const rev_i = cells.len - i - 1; + if (!cells[rev_i].isEmpty()) break :max rev_i + 1; + } + + break :max 0; }; // We're over at the max @@ -52,63 +57,60 @@ pub const RunIterator = struct { var j: usize = self.i; while (j < max) : (j += 1) { const cluster = j; - const cell = self.row.getCell(j); + const cell = &cells[j]; // If we have a selection and we're at a boundary point, then // we break the run here. - if (self.selection) |unordered_sel| { - if (j > self.i) { - const sel = unordered_sel.ordered(.forward); - - if (sel.start.x > 0 and - j == sel.start.x and - self.row.graphemeBreak(sel.start.x)) break; - - if (sel.end.x > 0 and - j == sel.end.x + 1 and - self.row.graphemeBreak(sel.end.x)) break; - } - } + // TODO(paged-terminal) + // if (self.selection) |unordered_sel| { + // if (j > self.i) { + // const sel = unordered_sel.ordered(.forward); + // + // if (sel.start.x > 0 and + // j == sel.start.x and + // self.row.graphemeBreak(sel.start.x)) break; + // + // if (sel.end.x > 0 and + // j == sel.end.x + 1 and + // self.row.graphemeBreak(sel.end.x)) break; + // } + // } // If we're a spacer, then we ignore it - if (cell.attrs.wide_spacer_tail) continue; + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } // If our cell attributes are changing, then we split the run. // This prevents a single glyph for ">=" to be rendered with // one color when the two components have different styling. if (j > self.i) { - const prev_cell = self.row.getCell(j - 1); - const Attrs = @TypeOf(cell.attrs); - const Int = @typeInfo(Attrs).Struct.backing_integer.?; - const prev_attrs: Int = @bitCast(prev_cell.attrs.styleAttrs()); - const attrs: Int = @bitCast(cell.attrs.styleAttrs()); - if (prev_attrs != attrs) break; - if (!cell.bg.eql(prev_cell.bg)) break; - if (!cell.fg.eql(prev_cell.fg)) break; + const prev_cell = cells[j - 1]; + if (prev_cell.style_id != cell.style_id) break; } // Text runs break when font styles change so we need to get // the proper style. const style: font.Style = style: { - if (cell.attrs.bold) { - if (cell.attrs.italic) break :style .bold_italic; - break :style .bold; - } - - if (cell.attrs.italic) break :style .italic; + // TODO(paged-terminal) + // if (cell.attrs.bold) { + // if (cell.attrs.italic) break :style .bold_italic; + // break :style .bold; + // } + // + // if (cell.attrs.italic) break :style .italic; break :style .regular; }; // Determine the presentation format for this glyph. - const presentation: ?font.Presentation = if (cell.attrs.grapheme) p: { + const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { // We only check the FIRST codepoint because I believe the // presentation format must be directly adjacent to the codepoint. - var it = self.row.codepointIterator(j); - if (it.next()) |cp| { - if (cp == 0xFE0E) break :p .text; - if (cp == 0xFE0F) break :p .emoji; - } - + const cps = self.row.grapheme(cell) orelse break :p null; + assert(cps.len > 0); + if (cps[0] == 0xFE0E) break :p .text; + if (cps[0] == 0xFE0F) break :p .emoji; break :p null; } else emoji: { // If we're not a grapheme, our individual char could be @@ -128,7 +130,7 @@ pub const RunIterator = struct { // such as a skin-tone emoji is fine, but hovering over the // joiners will show the joiners allowing you to modify the // emoji. - if (!cell.attrs.grapheme) { + if (!cell.hasGrapheme()) { if (self.cursor_x) |cursor_x| { // Exactly: self.i is the cursor and we iterated once. This // means that we started exactly at the cursor and did at @@ -163,7 +165,6 @@ pub const RunIterator = struct { // then we use that. if (try self.indexForCell( alloc, - j, cell, style, presentation, @@ -206,12 +207,12 @@ pub const RunIterator = struct { // Add all the codepoints for our grapheme try self.hooks.addCodepoint( - if (cell.char == 0) ' ' else cell.char, + if (cell.codepoint() == 0) ' ' else cell.codepoint(), @intCast(cluster), ); - if (cell.attrs.grapheme) { - var it = self.row.codepointIterator(j); - while (it.next()) |cp| { + if (cell.hasGrapheme()) { + const cps = self.row.grapheme(cell).?; + for (cps) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; try self.hooks.addCodepoint(cp, @intCast(cluster)); @@ -242,13 +243,12 @@ pub const RunIterator = struct { fn indexForCell( self: *RunIterator, alloc: Allocator, - j: usize, - cell: terminal.Screen.Cell, + cell: *terminal.Cell, style: font.Style, presentation: ?font.Presentation, ) !?font.Group.FontIndex { // Get the font index for the primary codepoint. - const primary_cp: u32 = if (cell.empty() or cell.char == 0) ' ' else cell.char; + const primary_cp: u32 = if (cell.isEmpty() or cell.codepoint() == 0) ' ' else cell.codepoint(); const primary = try self.group.indexForCodepoint( alloc, primary_cp, @@ -258,16 +258,16 @@ pub const RunIterator = struct { // Easy, and common: we aren't a multi-codepoint grapheme, so // we just return whatever index for the cell codepoint. - if (!cell.attrs.grapheme) return primary; + if (!cell.hasGrapheme()) return primary; // If this is a grapheme, we need to find a font that supports // all of the codepoints in the grapheme. - var it = self.row.codepointIterator(j); - var candidates = try std.ArrayList(font.Group.FontIndex).initCapacity(alloc, it.len() + 1); + const cps = self.row.grapheme(cell) orelse return primary; + var candidates = try std.ArrayList(font.Group.FontIndex).initCapacity(alloc, cps.len + 1); defer candidates.deinit(); candidates.appendAssumeCapacity(primary); - while (it.next()) |cp| { + for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; @@ -285,8 +285,7 @@ pub const RunIterator = struct { // We need to find a candidate that has ALL of our codepoints for (candidates.items) |idx| { if (!self.group.group.hasCodepoint(idx, primary_cp, presentation)) continue; - it.reset(); - while (it.next()) |cp| { + for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; if (!self.group.group.hasCodepoint(idx, cp, presentation)) break; diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index aae4c1c9ea..dc72bb8810 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -2053,6 +2053,12 @@ pub const Pin = struct { }; } + /// Returns the grapheme codepoints for the given cell. These are only + /// the EXTRA codepoints and not the first codepoint. + pub fn grapheme(self: Pin, cell: *pagepkg.Cell) ?[]u21 { + return self.page.data.lookupGrapheme(cell); + } + /// Iterators. These are the same as PageList iterator funcs but operate /// on pins rather than points. This is MUCH more efficient than calling /// pointFromPin and building up the iterator from points. diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 8945f4ea5e..7eb6c509e2 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -22,6 +22,7 @@ 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; diff --git a/src/terminal2/page.zig b/src/terminal2/page.zig index b9bd9b993c..69d2867098 100644 --- a/src/terminal2/page.zig +++ b/src/terminal2/page.zig @@ -748,10 +748,10 @@ pub const Cell = packed struct(u64) { }; /// Helper to make a cell that just has a codepoint. - pub fn init(codepoint: u21) Cell { + pub fn init(cp: u21) Cell { return .{ .content_tag = .codepoint, - .content = .{ .codepoint = codepoint }, + .content = .{ .codepoint = cp }, }; } @@ -767,6 +767,18 @@ pub const Cell = packed struct(u64) { }; } + pub fn codepoint(self: Cell) u21 { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint, + + .bg_color_palette, + .bg_color_rgb, + => 0, + }; + } + pub fn hasStyling(self: Cell) bool { return self.style_id != style.default_id; } From 34200a3e833cad188abf0378e5e028daa37a4ee3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 09:40:18 -0800 Subject: [PATCH 227/428] font/shaper: more tests passing --- src/font/shaper/harfbuzz.zig | 574 ++++++++++++++++++++--------------- src/font/shaper/run.zig | 28 +- 2 files changed, 350 insertions(+), 252 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index cf1d9c8903..f2532800cb 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -84,6 +84,7 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, group: *GroupCache, + screen: *const terminal.Screen, row: terminal.Pin, selection: ?terminal.Selection, cursor_x: ?usize, @@ -91,6 +92,7 @@ pub const Shaper = struct { return .{ .hooks = .{ .shaper = self }, .group = group, + .screen = screen, .row = row, .selection = selection, .cursor_x = cursor_x, @@ -250,6 +252,7 @@ test "run iterator" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -268,6 +271,7 @@ test "run iterator" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -287,6 +291,7 @@ test "run iterator" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -337,36 +342,42 @@ test "run iterator" { // try testing.expectEqual(@as(usize, 1), count); // } // } -// -// test "shape" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// 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 -// -// // Make a screen with some data -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString(buf[0..buf_idx]); -// -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } + +test "shape" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + 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 + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 10, 3, 0); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); +} test "shape inconsolata ligs" { const testing = std.testing; @@ -383,6 +394,7 @@ test "shape inconsolata ligs" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -407,6 +419,7 @@ test "shape inconsolata ligs" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -440,6 +453,7 @@ test "shape monaspace ligs" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -473,6 +487,7 @@ test "shape emoji width" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -512,6 +527,7 @@ test "shape emoji width long" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -550,6 +566,7 @@ test "shape variation selector VS15" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -586,6 +603,7 @@ test "shape variation selector VS16" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -619,6 +637,7 @@ test "shape with empty cells in between" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -656,6 +675,7 @@ test "shape Chinese characters" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -702,6 +722,7 @@ test "shape box glyphs" { var shaper = &testdata.shaper; var it = shaper.runIterator( testdata.cache, + &screen, screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, null, null, @@ -720,214 +741,291 @@ test "shape box glyphs" { try testing.expectEqual(@as(usize, 1), count); } -// test "shape selection boundary" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// // Make a screen with some data -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString("a1b2c3d4e5"); -// -// // Full line selection -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ -// .start = .{ .x = 0, .y = 0 }, -// .end = .{ .x = screen.cols - 1, .y = 0 }, -// }, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// -// // Offset x, goes to end of line selection -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ -// .start = .{ .x = 2, .y = 0 }, -// .end = .{ .x = screen.cols - 1, .y = 0 }, -// }, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Offset x, starts at beginning of line -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ -// .start = .{ .x = 0, .y = 0 }, -// .end = .{ .x = 3, .y = 0 }, -// }, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Selection only subset of line -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ -// .start = .{ .x = 1, .y = 0 }, -// .end = .{ .x = 3, .y = 0 }, -// }, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 3), count); -// } -// -// // Selection only one character -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{ -// .start = .{ .x = 1, .y = 0 }, -// .end = .{ .x = 1, .y = 0 }, -// }, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 3), count); -// } -// } -// -// test "shape cursor boundary" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// // Make a screen with some data -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString("a1b2c3d4e5"); -// -// // No cursor is full line -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// -// // Cursor at index 0 is two runs -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Cursor at index 1 is three runs -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 3), count); -// } -// -// // Cursor at last col is two runs -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// } -// -// test "shape cursor boundary and colored emoji" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// // Make a screen with some data -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString("👍🏼"); -// -// // No cursor is full line -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// -// // Cursor on emoji does not split it -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// { -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// } -// +test "shape selection boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 10, 3, 0); + defer screen.deinit(); + try screen.testWriteString("a1b2c3d4e5"); + + // Full line selection + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, + false, + ), + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Offset x, goes to end of line selection + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, + false, + ), + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Offset x, starts at beginning of line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + ), + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Selection only subset of line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + ), + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + + // Selection only one character + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + terminal.Selection.init( + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + false, + ), + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } +} + +test "shape cursor boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 10, 3, 0); + defer screen.deinit(); + try screen.testWriteString("a1b2c3d4e5"); + + // No cursor is full line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 9, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } +} + +test "shape cursor boundary and colored emoji" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString("👍🏼"); + + // No cursor is full line + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Cursor on emoji does not split it + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 0, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + 1, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } +} + // test "shape cell attribute change" { // const testing = std.testing; // const alloc = testing.allocator; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 3ef900138e..b98f0fd49b 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -26,6 +26,7 @@ pub const TextRun = struct { pub const RunIterator = struct { hooks: font.Shaper.RunIteratorHook, group: *font.GroupCache, + screen: *const terminal.Screen, row: terminal.Pin, selection: ?terminal.Selection = null, cursor_x: ?usize = null, @@ -61,20 +62,19 @@ pub const RunIterator = struct { // If we have a selection and we're at a boundary point, then // we break the run here. - // TODO(paged-terminal) - // if (self.selection) |unordered_sel| { - // if (j > self.i) { - // const sel = unordered_sel.ordered(.forward); - // - // if (sel.start.x > 0 and - // j == sel.start.x and - // self.row.graphemeBreak(sel.start.x)) break; - // - // if (sel.end.x > 0 and - // j == sel.end.x + 1 and - // self.row.graphemeBreak(sel.end.x)) break; - // } - // } + if (self.selection) |unordered_sel| { + if (j > self.i) { + const sel = unordered_sel.ordered(self.screen, .forward); + const start_x = sel.start().x; + const end_x = sel.end().x; + + if (start_x > 0 and + j == start_x) break; + + if (end_x > 0 and + j == end_x + 1) break; + } + } // If we're a spacer, then we ignore it switch (cell.wide) { From efe037bb9f2bc5f0140d57b32534935883e0329a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 09:48:44 -0800 Subject: [PATCH 228/428] font/shaper: test with bg only cells --- src/font/shaper/harfbuzz.zig | 91 ++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index f2532800cb..17a09c3ad6 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -307,41 +307,62 @@ test "run iterator" { } } -// test "run iterator: empty cells with background set" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// { -// // Make a screen with some data -// var screen = try terminal.Screen.init(alloc, 3, 5, 0); -// defer screen.deinit(); -// screen.cursor.pen.bg = .{ .rgb = try terminal.color.Name.cyan.default() }; -// try screen.testWriteString("A"); -// -// // Get our first row -// const row = screen.getRow(.{ .active = 0 }); -// row.getCellPtr(1).* = screen.cursor.pen; -// row.getCellPtr(2).* = screen.cursor.pen; -// -// // Get our run iterator -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// -// // The run should have length 3 because of the two background -// // cells. -// try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); -// const cells = try shaper.shape(run); -// try testing.expectEqual(@as(usize, 3), cells.len); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// } +test "run iterator: empty cells with background set" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + { + // Make a screen with some data + var screen = try terminal.Screen.init(alloc, 5, 3, 0); + defer screen.deinit(); + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + try screen.testWriteString("A"); + + // Get our first row + { + const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; + const cell = list_cell.cell; + cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + { + const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; + const cell = list_cell.cell; + cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + { + const run = (try it.next(alloc)).?; + try testing.expectEqual(@as(u32, 1), shaper.hb_buf.getLength()); + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 1), cells.len); + } + { + const run = (try it.next(alloc)).?; + try testing.expectEqual(@as(u32, 2), shaper.hb_buf.getLength()); + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 2), cells.len); + } + try testing.expect(try it.next(alloc) == null); + } +} test "shape" { const testing = std.testing; From 05470bb36a11f5cbaefb141fbaa5f67a37dcb1bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 09:58:19 -0800 Subject: [PATCH 229/428] font/shaper: new API --- src/font/shaper/harfbuzz.zig | 224 ++++++++++++++++++++--------------- src/font/shaper/run.zig | 24 ++-- src/terminal2/PageList.zig | 9 ++ 3 files changed, 149 insertions(+), 108 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 17a09c3ad6..29a7e315f7 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1047,103 +1047,133 @@ test "shape cursor boundary and colored emoji" { } } -// test "shape cell attribute change" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var testdata = try testShaper(alloc); -// defer testdata.deinit(); -// -// // Plain >= should shape into 1 run -// { -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString(">="); -// -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// -// // Bold vs regular should split -// { -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// try screen.testWriteString(">"); -// screen.cursor.pen.attrs.bold = true; -// try screen.testWriteString("="); -// -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Changing fg color should split -// { -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// screen.cursor.pen.fg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; -// try screen.testWriteString(">"); -// screen.cursor.pen.fg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; -// try screen.testWriteString("="); -// -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Changing bg color should split -// { -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; -// try screen.testWriteString(">"); -// screen.cursor.pen.bg = .{ .rgb = .{ .r = 3, .g = 2, .b = 1 } }; -// try screen.testWriteString("="); -// -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 2), count); -// } -// -// // Same bg color should not split -// { -// var screen = try terminal.Screen.init(alloc, 3, 10, 0); -// defer screen.deinit(); -// screen.cursor.pen.bg = .{ .rgb = .{ .r = 1, .g = 2, .b = 3 } }; -// try screen.testWriteString(">"); -// try screen.testWriteString("="); -// -// var shaper = &testdata.shaper; -// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null); -// var count: usize = 0; -// while (try it.next(alloc)) |run| { -// count += 1; -// _ = try shaper.shape(run); -// } -// try testing.expectEqual(@as(usize, 1), count); -// } -// } +test "shape cell attribute change" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaper(alloc); + defer testdata.deinit(); + + // Plain >= should shape into 1 run + { + var screen = try terminal.Screen.init(alloc, 10, 3, 0); + defer screen.deinit(); + try screen.testWriteString(">="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + + // Bold vs regular should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.testWriteString(">"); + try screen.setAttribute(.{ .bold = {} }); + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Changing fg color should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + try screen.testWriteString(">"); + try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Changing bg color should split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + try screen.testWriteString(">"); + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + + // Same bg color should not split + { + var screen = try terminal.Screen.init(alloc, 3, 10, 0); + defer screen.deinit(); + try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + try screen.testWriteString(">"); + try screen.testWriteString("="); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.cache, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } +} const TestShaper = struct { alloc: Allocator, diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index b98f0fd49b..7a6c4e5543 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -54,6 +54,9 @@ pub const RunIterator = struct { // Allow the hook to prepare try self.hooks.prepare(); + // Let's get our style that we'll expect for the run. + const style = self.row.style(&cells[0]); + // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; while (j < max) : (j += 1) { @@ -92,14 +95,13 @@ pub const RunIterator = struct { // Text runs break when font styles change so we need to get // the proper style. - const style: font.Style = style: { - // TODO(paged-terminal) - // if (cell.attrs.bold) { - // if (cell.attrs.italic) break :style .bold_italic; - // break :style .bold; - // } - // - // if (cell.attrs.italic) break :style .italic; + const font_style: font.Style = style: { + if (style.flags.bold) { + if (style.flags.italic) break :style .bold_italic; + break :style .bold; + } + + if (style.flags.italic) break :style .italic; break :style .regular; }; @@ -166,7 +168,7 @@ pub const RunIterator = struct { if (try self.indexForCell( alloc, cell, - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx }; @@ -175,7 +177,7 @@ pub const RunIterator = struct { if (try self.group.indexForCodepoint( alloc, 0xFFFD, // replacement char - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; @@ -183,7 +185,7 @@ pub const RunIterator = struct { if (try self.group.indexForCodepoint( alloc, ' ', - style, + font_style, presentation, )) |idx| break :font_info .{ .idx = idx, .fallback = ' ' }; diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index dc72bb8810..fb4005991e 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -2059,6 +2059,15 @@ pub const Pin = struct { return self.page.data.lookupGrapheme(cell); } + /// Returns the style for the given cell in this pin. + pub fn style(self: Pin, cell: *pagepkg.Cell) stylepkg.Style { + if (cell.style_id == stylepkg.default_id) return .{}; + return self.page.data.styles.lookupId( + self.page.data.memory, + cell.style_id, + ).?.*; + } + /// Iterators. These are the same as PageList iterator funcs but operate /// on pins rather than points. This is MUCH more efficient than calling /// pointFromPin and building up the iterator from points. From cc4b5df9de687cc501ac5c332829f997aac9dc95 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 10:14:10 -0800 Subject: [PATCH 230/428] terminal2: export CursorStyle --- src/terminal2/main.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 7eb6c509e2..25a97cb2e0 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -35,6 +35,7 @@ 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; From 9b4ab0e209b84890cf53182078a6c42d85553263 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 10:17:41 -0800 Subject: [PATCH 231/428] zig build test with renamed terminal package --- src/Surface.zig | 3 +- src/bench/stream.zig | 4 +- src/config/Config.zig | 2 +- src/font/shaper/harfbuzz.zig | 2 +- src/font/shaper/run.zig | 2 +- src/inspector/Inspector.zig | 8 +- src/main_ghostty.zig | 2 +- src/renderer/cursor.zig | 10 +- src/renderer/link.zig | 273 +- src/{terminal2 => terminal-old}/Parser.zig | 0 src/terminal-old/Screen.zig | 7920 ++++++++++++ src/terminal-old/Selection.zig | 1165 ++ src/{terminal => terminal-old}/StringMap.zig | 0 src/{terminal2 => terminal-old}/Tabstops.zig | 0 src/{terminal2 => terminal-old}/Terminal.zig | 9173 +++++++------- .../UTF8Decoder.zig | 0 src/{terminal2 => terminal-old}/ansi.zig | 0 src/{terminal2 => terminal-old}/apc.zig | 0 src/{terminal2 => terminal-old}/charsets.zig | 0 src/{terminal2 => terminal-old}/color.zig | 0 src/{terminal2 => terminal-old}/csi.zig | 0 src/{terminal2 => terminal-old}/dcs.zig | 0 .../device_status.zig | 0 src/{terminal2 => terminal-old}/kitty.zig | 0 .../kitty/graphics.zig | 0 .../kitty/graphics_command.zig | 0 .../kitty/graphics_exec.zig | 0 .../kitty/graphics_image.zig | 13 +- .../kitty/graphics_storage.zig | 269 +- src/{terminal2 => terminal-old}/kitty/key.zig | 0 .../image-png-none-50x76-2147483647-raw.data | Bin .../image-rgb-none-20x15-2147483647.data | 0 ...ge-rgb-zlib_deflate-128x96-2147483647.data | 0 src/{terminal2 => terminal-old}/main.zig | 25 +- src/{terminal2 => terminal-old}/modes.zig | 0 .../mouse_shape.zig | 0 src/{terminal2 => terminal-old}/osc.zig | 0 .../parse_table.zig | 0 src/terminal-old/point.zig | 254 + src/{terminal2 => terminal-old}/res/rgb.txt | 0 src/{terminal2 => terminal-old}/sanitize.zig | 0 src/{terminal2 => terminal-old}/sgr.zig | 0 src/{terminal => terminal-old}/simdvt.zig | 0 src/{terminal2 => terminal-old}/stream.zig | 0 src/{terminal => terminal-old}/wasm.zig | 0 src/{terminal2 => terminal-old}/x11_color.zig | 0 src/{terminal2 => terminal}/PageList.zig | 0 src/terminal/Screen.zig | 10166 ++++++---------- src/terminal/Selection.zig | 1735 +-- src/terminal/Terminal.zig | 9163 +++++++------- .../bitmap_allocator.zig | 0 src/{terminal2 => terminal}/hash_map.zig | 0 src/terminal/kitty/graphics_image.zig | 13 +- src/terminal/kitty/graphics_storage.zig | 269 +- src/terminal/main.zig | 25 +- src/{terminal2 => terminal}/page.zig | 0 src/terminal/point.zig | 300 +- src/{terminal2 => terminal}/size.zig | 0 src/{terminal2 => terminal}/style.zig | 0 src/terminal2/Screen.zig | 5536 --------- src/terminal2/Selection.zig | 1230 -- src/terminal2/point.zig | 86 - src/termio/Exec.zig | 22 +- 63 files changed, 23837 insertions(+), 23833 deletions(-) rename src/{terminal2 => terminal-old}/Parser.zig (100%) create mode 100644 src/terminal-old/Screen.zig create mode 100644 src/terminal-old/Selection.zig rename src/{terminal => terminal-old}/StringMap.zig (100%) rename src/{terminal2 => terminal-old}/Tabstops.zig (100%) rename src/{terminal2 => terminal-old}/Terminal.zig (73%) rename src/{terminal2 => terminal-old}/UTF8Decoder.zig (100%) rename src/{terminal2 => terminal-old}/ansi.zig (100%) rename src/{terminal2 => terminal-old}/apc.zig (100%) rename src/{terminal2 => terminal-old}/charsets.zig (100%) rename src/{terminal2 => terminal-old}/color.zig (100%) rename src/{terminal2 => terminal-old}/csi.zig (100%) rename src/{terminal2 => terminal-old}/dcs.zig (100%) rename src/{terminal2 => terminal-old}/device_status.zig (100%) rename src/{terminal2 => terminal-old}/kitty.zig (100%) rename src/{terminal2 => terminal-old}/kitty/graphics.zig (100%) rename src/{terminal2 => terminal-old}/kitty/graphics_command.zig (100%) rename src/{terminal2 => terminal-old}/kitty/graphics_exec.zig (100%) rename src/{terminal2 => terminal-old}/kitty/graphics_image.zig (98%) rename src/{terminal2 => terminal-old}/kitty/graphics_storage.zig (75%) rename src/{terminal2 => terminal-old}/kitty/key.zig (100%) rename src/{terminal2 => terminal-old}/kitty/testdata/image-png-none-50x76-2147483647-raw.data (100%) rename src/{terminal2 => terminal-old}/kitty/testdata/image-rgb-none-20x15-2147483647.data (100%) rename src/{terminal2 => terminal-old}/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data (100%) rename src/{terminal2 => terminal-old}/main.zig (81%) rename src/{terminal2 => terminal-old}/modes.zig (100%) rename src/{terminal2 => terminal-old}/mouse_shape.zig (100%) rename src/{terminal2 => terminal-old}/osc.zig (100%) rename src/{terminal2 => terminal-old}/parse_table.zig (100%) create mode 100644 src/terminal-old/point.zig rename src/{terminal2 => terminal-old}/res/rgb.txt (100%) rename src/{terminal2 => terminal-old}/sanitize.zig (100%) rename src/{terminal2 => terminal-old}/sgr.zig (100%) rename src/{terminal => terminal-old}/simdvt.zig (100%) rename src/{terminal2 => terminal-old}/stream.zig (100%) rename src/{terminal => terminal-old}/wasm.zig (100%) rename src/{terminal2 => terminal-old}/x11_color.zig (100%) rename src/{terminal2 => terminal}/PageList.zig (100%) rename src/{terminal2 => terminal}/bitmap_allocator.zig (100%) rename src/{terminal2 => terminal}/hash_map.zig (100%) rename src/{terminal2 => terminal}/page.zig (100%) rename src/{terminal2 => terminal}/size.zig (100%) rename src/{terminal2 => terminal}/style.zig (100%) delete mode 100644 src/terminal2/Screen.zig delete mode 100644 src/terminal2/Selection.zig delete mode 100644 src/terminal2/point.zig diff --git a/src/Surface.zig b/src/Surface.zig index e61977d539..815109dbc3 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 3e6262014e..4d7586be4f 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 4a0ba75ec1..eedd6932a3 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 29a7e315f7..04143c090d 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 7a6c4e5543..c1f483778b 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 6a4235a7ea..11ef18a06a 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 4e99001c67..497631f31a 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 c4a74e05c6..fd58257bee 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 ca1dc062a2..4c16ed3a2b 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 0000000000..385ce1eba1 --- /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 0000000000..d29513d73e --- /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 94d33f7343..5ff2591cbe 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,422 +191,500 @@ 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(); - }, +/// 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, +}; - else => try self.print(cp), - } - } -} +/// 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 }); -/// 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); - } -} + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + // for now, we ignore... + if (self.active_screen == .alternate) return; -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 requested cursor save, we save the cursor in the primary screen + if (options.cursor_save) self.saveCursor(); - // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .alternate; - // 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; + // Bring our pen with us + self.screen.cursor = old.cursor; - // 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; + // Bring our charset state with us + self.screen.charset = old.charset; - // 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()); - }; + // Clear our selection + self.screen.selection = null; - // 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, - }, - }; - }; + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; - // If our cell has no content, then this is a new cell and - // necessarily a grapheme break. - if (!prev.cell.hasText()) break :grapheme; + if (options.clear_on_enter) { + self.eraseDisplay(alloc, .complete, false); + } +} - 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; - } - } +/// 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 }); - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); - }; + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + if (self.active_screen == .primary) return; - // 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; + if (options.clear_on_exit) self.eraseDisplay(alloc, .complete, false); - switch (c) { - 0xFE0F => wide: { - if (prev.cell.wide == .wide) break :wide; + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screen.cursorLeft(prev.left); + // Clear our selection + self.screen.selection = null; - // 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(); - } + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; - self.printCell(prev.cell.content.codepoint, .wide); + // Restore the cursor from the primary screen + if (options.cursor_save) self.restoreCursor(); +} - // Write our spacer - self.screen.cursorRight(1); - self.printCell(' ', .spacer_tail); +/// The modes for DECCOLM. +pub const DeccolmMode = enum(u1) { + @"80_cols" = 0, + @"132_cols" = 1, +}; - // 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); - } - }, +/// DECCOLM changes the terminal width between 80 and 132 columns. This +/// function call will do NOTHING unless `setDeccolmSupported` has been +/// called with "true". +/// +/// This breaks the expectation around modern terminals that they resize +/// with the window. This will fix the grid at either 80 or 132 columns. +/// The rows will continue to be variable. +pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if (!self.modes.get(.enable_mode_3)) { + self.modes.set(.@"132_column", false); + return; + } - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (prev.cell.wide != .wide) break :narrow; - prev.cell.wide = .narrow; + // Enable it + self.modes.set(.@"132_column", mode == .@"132_cols"); - // Remove the wide spacer tail - const cell = self.screen.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; + // Resize to the requested size + try self.resize( + alloc, + switch (mode) { + .@"132_cols" => 132, + .@"80_cols" => 80, + }, + self.rows, + ); - break :narrow; - }, + // Erase our display and move our cursor. + self.eraseDisplay(alloc, .complete, false); + self.setCursorPos(1, 1); +} - else => unreachable, - } - } +/// Resize the underlying terminal. +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; - 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; + // 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); + } + + // If we're making the screen smaller, dealloc the unused items. + if (self.active_screen == .primary) { + self.clearPromptForResize(); + if (self.modes.get(.wraparound)) { + try self.screen.resize(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + } + try self.secondary_screen.resizeWithoutReflow(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + if (self.modes.get(.wraparound)) { + try self.secondary_screen.resize(rows, cols); + } else { + try self.secondary_screen.resizeWithoutReflow(rows, cols); } } - // 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); + // Set our size + self.cols = cols; + self.rows = rows; - // 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 }); + // Reset the scrolling region + self.scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }; +} - // 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 shell_redraws_prompt is true and we're on the primary screen, +/// 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 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 we have command output, then we're most certainly not + // at a prompt. Break out of the loop. + .command => break, - // 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; + // If we don't know, we keep searching. + .unknown => {}, + } } - try self.screen.cursor.page_pin.page.data.appendGrapheme( - self.screen.cursor.page_row, - prev, - c, - ); + 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(.{}); } +} - // We have a printable character, save it - self.previous_char = c; +/// 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); +} - // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { - try self.printWrap(); - } +/// 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, + }; +} - // 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); - } +/// 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 = .{}, + }; - switch (width) { - // Single cell is very easy: just write in the cell - 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), + 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; +} - // 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; +/// 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 = .{}; + }, - self.printCell(' ', .spacer_head); - try self.printWrap(); - } + .bold => { + self.screen.cursor.pen.attrs.bold = true; + }, - 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); + .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; }, - else => unreachable, - } + .italic => { + self.screen.cursor.pen.attrs.italic = true; + }, - // 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; - } + .reset_italic => { + self.screen.cursor.pen.attrs.italic = false; + }, - // Move the cursor - self.screen.cursorRight(1); -} + .faint => { + self.screen.cursor.pen.attrs.faint = true; + }, -fn printCell( - self: *Terminal, - unmapped_c: u21, - wide: Cell.Wide, -) void { - // TODO: spacers should use a bgcolor only cell + .underline => |v| { + self.screen.cursor.pen.attrs.underline = v; + }, - const c: u21 = c: { - // TODO: non-utf8 handling, gr + .reset_underline => { + self.screen.cursor.pen.attrs.underline = .none; + }, - // 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); + .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, + }; + }, - // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; + .@"256_underline_color" => |idx| { + self.screen.cursor.pen.attrs.underline_color = true; + self.screen.cursor.pen.underline_fg = self.color_palette.colors[idx]; + }, - // 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 ' '; + .reset_underline_color => { + self.screen.cursor.pen.attrs.underline_color = false; + }, - // Get our lookup table and map it - const table = set.table(); - break :c @intCast(table[@intCast(unmapped_c)]); - }; + .blink => { + log.warn("blink requested, but not implemented", .{}); + self.screen.cursor.pen.attrs.blink = true; + }, - 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; - } - }, + .reset_blink => { + self.screen.cursor.pen.attrs.blink = false; + }, + + .inverse => { + self.screen.cursor.pen.attrs.inverse = true; + }, - .spacer_tail => { - assert(self.screen.cursor.x > 0); + .reset_inverse => { + self.screen.cursor.pen.attrs.inverse = false; + }, - 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; - } - }, + .invisible => { + self.screen.cursor.pen.attrs.invisible = true; + }, - // 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 => {}, - } + .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, } +} - // 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, - ); +/// 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; } - // Keep track of the previous style so we can decrement the ref count - const prev_style_id = cell.style_id; + if (pen.attrs.faint) { + attrs[i] = '2'; + i += 1; + } - // Write - cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = c }, - .style_id = self.screen.cursor.style_id, - .wide = wide, - .protected = self.screen.cursor.protected, - }; + if (pen.attrs.italic) { + attrs[i] = '3'; + i += 1; + } - // 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 (pen.attrs.underline != .none) { + attrs[i] = '4'; + i += 1; + } - // If we have a ref-counted style, increase. - if (self.screen.cursor.style_ref) |ref| { - ref.* += 1; - self.screen.cursor.page_row.styled = true; - } + if (pen.attrs.blink) { + attrs[i] = '5'; + i += 1; } -} -fn printWrap(self: *Terminal) !void { - self.screen.cursor.page_row.wrap = true; + if (pen.attrs.inverse) { + attrs[i] = '7'; + i += 1; + } - // 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; + if (pen.attrs.invisible) { + attrs[i] = '8'; + i += 1; + } - // Move to the next line - try self.index(); - self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); + if (pen.attrs.strikethrough) { + attrs[i] = '9'; + i += 1; + } - // 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; + 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. @@ -631,1094 +712,537 @@ pub fn invokeCharset( } } -/// 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; +/// 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(); + }, - // 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); + else => try self.print(cp), } - - 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, - ); - } - } +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - // Inserted lines should keep our bg color - for (0..adjusted_count) |i| { - const row: *Row = @ptrCast(top + i); + // If we're not on the main display, do nothing for now + if (self.status_display != .main) return; - // 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); - } + // 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; - // 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, - ); + // 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 }); - // Always unset pending wrap - self.screen.cursor.pending_wrap = false; -} + // 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; -/// 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; + // 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); - // 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; + // 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, + }; - // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + break :prev .{ + .cell = row.getCellPtr(x - 1), + .x = x - 1, + }; + }; - // The maximum we can delete is the remaining lines in the scroll region. - const count = @min(count_req, rem); + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (prev.cell.char == 0) break :grapheme; - // 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; + 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; + } } - // 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, - ); - } - } + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; - const bottom: [*]Row = top + (rem - 1); - while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { - const row: *Row = @ptrCast(y); + // 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; - // 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); - } + switch (c) { + 0xFE0F => wide: { + if (prev.cell.attrs.wide) break :wide; - // 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, - ); + // 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; - // Always unset pending wrap - self.screen.cursor.pending_wrap = false; -} + // 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(); + } -/// 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; + const wide_cell = self.printCell(@intCast(prev.cell.char)); + wide_cell.attrs.wide = true; - // 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; + // Write our spacer + self.screen.cursor.x += 1; + const spacer = self.printCell(' '); + spacer.attrs.wide_spacer_tail = true; - // 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; - // } + // 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; + } + }, - // 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; + 0xFE0E => narrow: { + // Prev cell is no longer wide + if (!prev.cell.attrs.wide) break :narrow; + prev.cell.attrs.wide = false; - // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + // Remove the wide spacer tail + const cell = row.getCellPtr(prev.x + 1); + cell.attrs.wide_spacer_tail = false; - // We can only insert blanks up to our remaining cols - const adjusted_count = @min(count, rem); + break :narrow; + }, - // 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); + else => unreachable, + } + } - // 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]); + log.debug("c={x} grapheme attach to x={}", .{ c, prev.x }); + try row.attachGrapheme(prev.x, c); + return; } + } - // 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); + // 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); - // 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); - } + // 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 }); - // Copy our src to our dst - const old_dst = dst.*; - dst.* = src.*; - src.* = old_dst; + // 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 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); - } + // 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; } - } - // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); -} + // 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; + }; -/// 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 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; + } - // 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; + try row.attachGrapheme(prev, c); + return; + } - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; + // We have a printable character, save it + self.previous_char = c; - // 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 we're soft-wrapping, then handle that first. + if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) + try self.printWrap(); - // 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]); + // 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); } - // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + switch (width) { + // Single cell is very easy: just write in the cell + 1 => _ = @call(.always_inline, printCell, .{ self, c }), - // We can only insert blanks up to our remaining cols - const adjusted_count = @min(count, rem); + // 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; - // 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); + const spacer_head = self.printCell(' '); + spacer_head.attrs.wide_spacer_head = true; + try self.printWrap(); + } - // 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]); - } + const wide_cell = self.printCell(c); + wide_cell.attrs.wide = true; - while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { - const src: *Cell = @ptrCast(x + count); - const dst: *Cell = @ptrCast(x); + // 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(' '); + }, - // 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); - } + else => unreachable, + } - // Copy our src to our dst - const old_dst = dst.*; - dst.* = src.*; - src.* = old_dst; + // Move the cursor + self.screen.cursor.x += 1; - // 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); - } - } + // 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; } - - // 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); +fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { + const c: u21 = c: { + // TODO: non-utf8 handling, gr - // This resets the soft-wrap of this line - self.screen.cursor.page_row.wrap = false; + // 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); - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) break :c unmapped_c; - // 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 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 ' '; - // 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; + // 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; } - break :end end; - }; + 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; - // Clear the cells - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + const wide_cell = row.getCellPtr(x); + wide_cell.* = self.screen.cursor.pen; - // 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; + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + self.clearWideSpacerHead(); + } } - // 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], - ); - } + // 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; } -/// 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; +fn printWrap(self: *Terminal) !void { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(true); - // 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; - } + // 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(); - // This resets the soft-wrap of this line - self.screen.cursor.page_row.wrap = false; + // Move to the next line + try self.index(); + self.screen.cursor.x = self.scrolling_region.left; - break :right .{ x, self.cols }; - }, + // 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); +} - .left => left: { - var x = self.screen.cursor.x; +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; +} - // If our x is a wide char we need to delete the tail too. - if (self.screen.cursor.page_cell.wide == .wide) { - x += 1; - } +/// 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); + } +} - break :left .{ 0, x + 1 }; - }, +/// 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); - // 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 }, + // Move our cursor to the top-left + self.setCursorPos(1, 1); - else => { - log.err("unimplemented erase line mode: {}", .{mode}); - return; + // 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, }, }; - // All modes will clear the pending wrap state and we know we have - // a valid mode at this point. + // 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; - // Start of our cells - const cells: [*]Cell = cells: { - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - break :cells cells - self.screen.cursor.x; - }; + // 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; + } - // 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 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); + } - // 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], - ); + // 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. @@ -1726,9 +1250,9 @@ pub fn eraseDisplay( switch (mode) { .scroll_complete => { - self.screen.scrollClear() catch |err| { + self.screen.scroll(.{ .clear = {} }) catch |err| { log.warn("scroll clear failed, doing a normal clear err={}", .{err}); - self.eraseDisplay(.complete, protected_req); + self.eraseDisplay(alloc, .complete, protected_req); return; }; @@ -1736,8 +1260,7 @@ pub fn eraseDisplay( self.screen.cursor.pending_wrap = false; // Clear all Kitty graphics state for this screen - // TODO - // self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + self.screen.kitty_images.delete(alloc, self, .{ .all = true }); }, .complete => { @@ -1747,66 +1270,86 @@ pub fn eraseDisplay( // 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, - ); + 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 - // TODO - //self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + self.screen.kitty_images.delete(alloc, self, .{ .all = true }); }, .below => { // All lines to the right (including the cursor) - self.eraseLine(.right, protected_req); + { + self.eraseLine(.right, protected_req); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(false); + row.setDirty(true); + } // All lines below - if (self.screen.cursor.y + 1 < self.rows) { - self.screen.clearRows( - .{ .active = .{ .y = self.screen.cursor.y + 1 } }, - null, - protected, - ); + 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. Should be done by eraseLine. - assert(!self.screen.cursor.pending_wrap); + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; }, .above => { @@ -1814,1935 +1357,1987 @@ pub fn eraseDisplay( 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, - ); + 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 - assert(!self.screen.cursor.pending_wrap); + self.screen.cursor.pending_wrap = false; }, - .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), + .scrollback => self.screen.clear(.history) catch |err| { + // This isn't a huge issue, so just log it. + log.err("failed to clear scrollback: {}", .{err}); + }, } } -/// 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, +/// 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 }, }; - // 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); + // 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; - // 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; + // 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; } - } - } -} -/// 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, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); -} + // This resets the soft-wrap of this line + row.setWrapped(false); -/// Set a style attribute. -pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - try self.screen.setAttribute(attr); -} + break :right .{ x, row.lenCells() }; + }, -/// 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(); + .left => left: { + var x = self.screen.cursor.x; - // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS - try writer.writeByte('0'); + // 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; + } + } - const pen = self.screen.cursor.style; - var attrs = [_]u8{0} ** 8; - var i: usize = 0; + break :left .{ 0, x + 1 }; + }, - if (pen.flags.bold) { - attrs[i] = '1'; - i += 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() }, - if (pen.flags.faint) { - attrs[i] = '2'; - i += 1; - } + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; - if (pen.flags.italic) { - attrs[i] = '3'; - i += 1; - } + // 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; - if (pen.flags.underline != .none) { - attrs[i] = '4'; - i += 1; - } + // 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 (pen.flags.blink) { - attrs[i] = '5'; - i += 1; + // 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; } - if (pen.flags.inverse) { - attrs[i] = '7'; - i += 1; + for (start..end) |x| { + const cell = row.getCellPtr(x); + if (cell.attrs.protected) continue; + cell.* = pen; } +} - if (pen.flags.invisible) { - attrs[i] = '8'; - i += 1; - } +/// 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; - if (pen.flags.strikethrough) { - attrs[i] = '9'; - i += 1; - } + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); - } + const pen: Screen.Cell = .{ + .bg = self.screen.cursor.pen.bg, + }; - 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), + // 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; + } } - 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), - } + // 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; + } - return stream.getWritten(); + 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; + } } -/// The modes for DECCOLM. -pub const DeccolmMode = enum(u1) { - @"80_cols" = 0, - @"132_cols" = 1, -}; +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = @max(count_req, 1); -/// DECCOLM changes the terminal width between 80 and 132 columns. This -/// function call will do NOTHING unless `setDeccolmSupported` has been -/// called with "true". -/// -/// This breaks the expectation around modern terminals that they resize -/// with the window. This will fix the grid at either 80 or 132 columns. -/// The rows will continue to be variable. -pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { - // If DEC mode 40 isn't enabled, then this is ignored. We also make - // sure that we don't have deccolm set because we want to fully ignore - // set mode. - if (!self.modes.get(.enable_mode_3)) { - self.modes.set(.@"132_column", false); - return; - } + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; - // Enable it - self.modes.set(.@"132_column", mode == .@"132_cols"); + // 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); - // Resize to the requested size - try self.resize( - alloc, - switch (mode) { - .@"132_cols" => 132, - .@"80_cols" => 80, - }, - self.rows, - ); + // 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; + } - // Erase our display and move our cursor. - self.eraseDisplay(.complete, false); - self.setCursorPos(1, 1); -} + break :end end; + }; -/// Resize the underlying terminal. -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; + // This resets the soft-wrap of this line + row.setWrapped(false); - // Resize our tabstops - if (self.cols != cols) { - self.tabstops.deinit(alloc); - self.tabstops = try Tabstops.init(alloc, cols, 8); - } + const pen: Screen.Cell = .{ + .bg = self.screen.cursor.pen.bg, + }; - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - self.clearPromptForResize(); - if (self.modes.get(.wraparound)) { - try self.screen.resize(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - } - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(rows, cols); - } else { - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } + // 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); } - // Set our size - self.cols = cols; - self.rows = rows; + // 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; + } +} - // Reset the scrolling region - self.scrolling_region = .{ - .top = 0, - .bottom = rows - 1, - .left = 0, - .right = cols - 1, +/// 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; }; -} -/// If shell_redraws_prompt is true and we're on the primary screen, -/// 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; -} + var count: usize = @max(count_req, 1); -/// Set the pwd for the terminal. -pub fn setPwd(self: *Terminal, pwd: []const u8) !void { - self.pwd.clearRetainingCapacity(); - try self.pwd.appendSlice(pwd); -} + // 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; + } -/// Returns the pwd for the terminal, if any. The memory is owned by the -/// Terminal and is not copied. It is safe until a reset or setPwd. -pub fn getPwd(self: *const Terminal) ?[]const u8 { - if (self.pwd.items.len == 0) return null; - return self.pwd.items; -} + // 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; + } -/// 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, -}; + // 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; -/// 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, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + // 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; + }, - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; + // Handled in while loop + .reverse_extended => {}, - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); + // Handled above + .none => unreachable, + } + } - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .alternate; + while (true) { + // We can move at most to the left margin. + const max = self.screen.cursor.x - left_margin; - // Bring our charset state with us - self.screen.charset = old.charset; + // 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; - // Clear our selection - self.screen.selection = null; + // If we have no more to move, then we're done. + if (count == 0) break; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // If we are at the top, then we are done. + if (self.screen.cursor.y == top) { + if (wrap_mode != .reverse_extended) break; - // 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); + self.screen.cursor.y = bottom; + self.screen.cursor.x = right_margin; + count -= 1; + continue; + } - if (options.clear_on_enter) { - self.eraseDisplay(.complete, false); - } + // 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; + } - // 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}); - }; + // 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; + } } -/// 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; +/// 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; - if (options.clear_on_exit) self.eraseDisplay(.complete, 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; - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; + const count = @max(count_req, 1); + self.screen.cursor.x = @min(max, self.screen.cursor.x +| count); +} - // Clear our selection - self.screen.selection = null; +/// 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; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // 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; - // 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}); - }; + const count = @max(count_req, 1); + self.screen.cursor.y = @min(max, self.screen.cursor.y +| count); } -/// 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 = .{} }); +/// 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); } -/// Full reset -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.saved_cursor = null; - self.screen.selection = null; - self.screen.kitty_keyboard = .{}; - self.screen.protected_mode = .off; - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; - self.previous_char = null; - self.eraseDisplay(.scrollback, false); - self.eraseDisplay(.complete, false); - self.screen.cursorAbsolute(0, 0); - self.pwd.clearRetainingCapacity(); - self.status_display = .main; +/// Backspace moves the cursor back a column (but not less than 0). +pub fn backspace(self: *Terminal) void { + self.cursorLeft(1); } -test "Terminal: input with no control characters" { - const alloc = testing.allocator; - var t = try init(alloc, 40, 40); - defer t.deinit(alloc); +/// 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; - // 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); - try testing.expectEqualStrings("hello", str); + // 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; } } -test "Terminal: input with basic wraparound" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 40); - defer t.deinit(alloc); +// 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; - // 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); + 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; } } -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); +/// 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}), } } -test "Terminal: zero-width character at start" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // This used to crash the terminal. This is not allowed so we should - // just ignore it. - try t.print(0x200D); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); +/// Set a tab stop on the current cursor. +/// TODO: test +pub fn tabSet(self: *Terminal) void { + self.tabstops.set(self.screen.cursor.x); } -// https://github.com/mitchellh/ghostty/issues/1400 -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'); +/// TODO: test +pub fn tabReset(self: *Terminal) void { + self.tabstops.reset(TABSTOP_INTERVAL); } -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); +/// 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; - { - 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); - } + // 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; } -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 +/// Linefeed moves the cursor to the next line. +pub fn linefeed(self: *Terminal) !void { + try self.index(); + if (self.modes.get(.linefeed)) self.carriageReturn(); } -test "Terminal: print wide char in single-width terminal" { - var t = try init(testing.allocator, 1, 80); - defer t.deinit(testing.allocator); +/// 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; - 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); + // 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) { - 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); + self.eraseLine(.right, false); + return; } -} -test "Terminal: print over wide char at 0,0" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); + // Get the current row + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - try t.print(0x1F600); // Smiley face - t.setCursorPos(0, 0); - try t.print('A'); // Smiley face + // Determine our indexes. + const start = self.screen.cursor.x; + const pivot = @min(self.screen.cursor.x + count, right_limit); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + // 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; - { - 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 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); + // 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); } -test "Terminal: print over wide spacer tail" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); +/// 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; - try t.print('橋'); - t.setCursorPos(1, 2); - try t.print('X'); + // 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; - { - 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); + // 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); + } } - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + // 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); } } -test "Terminal: print multicodepoint grapheme, disabled mode 2027" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // https://github.com/mitchellh/ghostty/issues/289 - // 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); +/// 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; - // We should have 6 cells taken up - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + // Move the cursor to the left margin + self.screen.cursor.x = self.scrolling_region.left; + self.screen.cursor.pending_wrap = false; - // 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, 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 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 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 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 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); - } + // 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) { - 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); + 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; } -} - -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); + // 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; - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + // 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; + } - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); + // 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); + } } - { - 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); + // 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); } } -test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +/// 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; - // https://github.com/mitchellh/ghostty/issues/1482 - try t.print('x'); - try t.print(0xFE0F); + // 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); +} - // 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); +/// 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; - // 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); - } + // 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); } -test "Terminal: print multicodepoint grapheme, mode 2027" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(enum) { + /// Scroll to the top of the scrollback + top: void, - // https://github.com/mitchellh/ghostty/issues/289 - // 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); + /// Scroll to the bottom, i.e. the top of the active area + bottom: void, - // 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, 2), t.screen.cursor.x); + /// Scroll by some delta amount, up is negative. + delta: isize, +}; - // 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, 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 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); - } +/// 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 }, + }); } -test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); +/// 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; - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + self.scrolling_region.top = top - 1; + self.scrolling_region.bottom = bottom - 1; + self.setCursorPos(1, 1); +} - try t.print(0x26C8); // Thunder cloud and rain - try t.print(0xFE0E); // VS15 to make narrow +/// 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 str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("⛈︎", str); - } + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; - { - 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); - } + self.scrolling_region.left = left - 1; + self.scrolling_region.right = right - 1; + self.setCursorPos(1, 1); } -test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); +/// 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, + }); +} - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); +/// 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; - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + 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, - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); - } + // If we have command output, then we're most certainly not + // at a prompt. + .command => return false, - { - 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); + // If we don't know, we keep searching. + .unknown => {}, + } } + + return false; } -test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); +/// Set the pwd for the terminal. +pub fn setPwd(self: *Terminal, pwd: []const u8) !void { + self.pwd.clearRetainingCapacity(); + try self.pwd.appendSlice(pwd); +} + +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[]const u8 { + if (self.pwd.items.len == 0) return null; + 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. +/// +/// 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, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); +/// 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; - 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 + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. + }, - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️❤️", str); - } + .iso => { + self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .iso; + }, - { - 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); + .dec => { + self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .dec; + }, } } -test "Terminal: print invalid VS16 grapheme" { +/// Full reset +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 = .{}; + self.screen.protected_mode = .off; + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + self.previous_char = null; + 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); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - // 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); + 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); - // 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); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } + 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); } -test "Terminal: print invalid VS16 with second char" { - var t = try init(testing.allocator, 80, 80); +// X +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, 10, 10); 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('y'); + t.setCursorPos(3, 5); + t.modes.set(.origin, true); + t.fullReset(testing.allocator); - // We should have 2 cells taken up. It is one character but "wide". + // 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, 2), 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, 'y'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(!t.modes.get(.origin)); } -test "Terminal: overwrite grapheme should clear grapheme data" { - var t = try init(testing.allocator, 5, 5); +// X +test "Terminal: fullReset status display" { + var t = try init(testing.allocator, 10, 10); 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 - t.setCursorPos(1, 1); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", 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.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } + t.status_display = .status_line; + t.fullReset(testing.allocator); + try testing.expect(t.status_display == .main); } -test "Terminal: print writes to bottom if scrolled" { - var t = try init(testing.allocator, 5, 2); +// X +test "Terminal: input with no control characters" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); // Basic grid writing for ("hello") |c| try t.print(c); - t.setCursorPos(0, 0); - - // Make newlines so we create scrollback - // 3 pushes hello off the screen - try t.index(); - try t.index(); - try t.index(); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } - - // Scroll to the top - t.screen.scroll(.{ .top = {} }); + 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); try testing.expectEqualStrings("hello", str); } - - // Type - try t.print('A'); - t.screen.scroll(.{ .active = {} }); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA", str); - } } -test "Terminal: print charset" { +// X +test "Terminal: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); + // This used to crash the terminal. This is not allowed so we should + // just ignore it. + try t.print(0x200D); - // Basic grid writing - try t.print('`'); - t.configureCharset(.G0, .utf8); - try t.print('`'); - t.configureCharset(.G0, .ascii); - try t.print('`'); - t.configureCharset(.G0, .dec_special); - try t.print('`'); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("```◆", str); - } + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, 80, 80); +// 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); - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); - - // Basic grid writing - t.configureCharset(.G0, .dec_special); - try t.print('`'); - try t.print(0x1F600); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("◆ ", str); - } + // This would crash for issue 1400. So the assertion here is + // that we simply do not crash. + for (0..500) |_| try t.print('x'); } -test "Terminal: print invoke charset" { +// X +test "Terminal: print over wide char at 0,0" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - t.configureCharset(.G1, .dec_special); + try t.print(0x1F600); // Smiley face + t.setCursorPos(0, 0); + try t.print('A'); // Smiley face - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, false); - try t.print('`'); - try t.print('`'); - t.invokeCharset(.GL, .G0, false); - try t.print('`'); + 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 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, 'A'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + } + { + const cell = row.getCell(1); + try testing.expect(!cell.attrs.wide_spacer_tail); } } -test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, 80, 80); +// X +test "Terminal: print over wide spacer tail" { + var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.configureCharset(.G1, .dec_special); + try t.print('橋'); + t.setCursorPos(1, 2); + try t.print('X'); - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, true); - try t.print('`'); - try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆`", str); + try testing.expectEqualStrings(" X", str); } -} - -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 row = t.screen.getRow(.{ .screen = 0 }); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hel\nlo", str); + 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)); } } -test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); +// X +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.markSemanticPrompt(.prompt); - for ("hello") |c| try t.print(c); + // 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 list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("⛈︎", str); } + + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + 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)); } } -test "Terminal: disabled wraparound with wide char and one space" { +// X +test "Terminal: VS16 to make wide character with mode 2027" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.modes.set(.wraparound, false); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // 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); + 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("AAAA", str); + try testing.expectEqualStrings("❤️", str); } - // Make sure we printed nothing + const row = t.screen.getRow(.{ .screen = 0 }); { - 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); + 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)); } } -test "Terminal: disabled wraparound with wide char and no space" { +// X +test "Terminal: VS16 repeated with mode 2027" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.modes.set(.wraparound, false); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // 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); + 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("AAAAA", 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 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); + 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)); } } -test "Terminal: disabled wraparound with wide grapheme and half space" { +// X +test "Terminal: VS16 doesn't make character with 2027 disabled" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.modes.set(.grapheme_cluster, true); - t.modes.set(.wraparound, false); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, 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); + try testing.expectEqualStrings("❤️", str); } + const row = t.screen.getRow(.{ .screen = 0 }); { - 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); + 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)); } } -test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, 10, 5); +// X +test "Terminal: print multicodepoint grapheme, disabled mode 2027" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 5); - try t.printString("XY"); + // https://github.com/mitchellh/ghostty/issues/289 + // 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 6 cells taken up + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 6), 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("1234X6789\n Y", str); + 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 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 cell = row.getCell(3); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); + } + { + 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 cell = row.getCell(5); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); } } -test "Terminal: print right margin outside" { - var t = try init(testing.allocator, 10, 5); +// X +test "Terminal: print multicodepoint grapheme, mode 2027" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 6); - try t.printString("XY"); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // 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 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, 2), 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("12345XY89", str); + 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 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: print right margin outside wrap" { - var t = try init(testing.allocator, 10, 5); +// X +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 10); - try t.printString("XY"); + // 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 row = t.screen.getRow(.{ .screen = 0 }); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("123456789X\n Y", 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 cell = row.getCell(1); + try testing.expectEqual(@as(u32, 0), cell.char); } } -test "Terminal: linefeed and carriage return" { +// X +test "Terminal: print invalid VS16 grapheme" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - // Basic grid writing - for ("hello") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("world") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // 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 row = t.screen.getRow(.{ .screen = 0 }); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello\nworld", 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 cell = row.getCell(1); + try testing.expectEqual(@as(u32, 0), cell.char); } } -test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); +// X +test "Terminal: print invalid VS16 with second char" { + 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.expect(t.screen.cursor.pending_wrap == true); - try t.linefeed(); - try testing.expect(t.screen.cursor.pending_wrap == false); + // 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('y'); + + // 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, 2), 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 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 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: linefeed mode automatic carriage return" { - var t = try init(testing.allocator, 10, 10); +// X +test "Terminal: soft wrap" { + var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); // Basic grid writing - t.modes.set(.linefeed, true); - try t.printString("123456"); - try t.linefeed(); - try t.print('X'); + 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("123456\nX", str); + try testing.expectEqualStrings("hel\nlo", str); } } -test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); +// X +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, 3, 80); defer t.deinit(testing.allocator); - // Basic grid writing + t.markSemanticPrompt(.prompt); for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); - t.carriageReturn(); - try testing.expect(t.screen.cursor.pending_wrap == false); + + { + 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); + } } -test "Terminal: carriage return origin mode moves to left margin" { - var t = try init(testing.allocator, 5, 80); +// 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(.origin, true); - t.screen.cursor.x = 0; - t.scrolling_region.left = 2; - t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + 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); + } } -test "Terminal: carriage return left of left margin moves to zero" { - var t = try init(testing.allocator, 5, 80); +// 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.screen.cursor.x = 1; - t.scrolling_region.left = 2; - t.carriageReturn(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); -} + t.modes.set(.wraparound, false); -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); + // 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); - t.screen.cursor.x = 3; - t.scrolling_region.left = 2; - t.carriageReturn(); - 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("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); + } } -test "Terminal: backspace" { - var t = try init(testing.allocator, 80, 80); +// X +test "Terminal: disabled wraparound with wide grapheme and half space" { + var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - // BS - for ("hello") |c| try t.print(c); - t.backspace(); - try t.print('y'); + 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, 5), t.screen.cursor.x); + 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("helly", str); + try testing.expectEqualStrings("AAAA❤", str); } -} -test "Terminal: horizontal tabs" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); + // 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); + } +} - // HT - try t.print('1'); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); +// X +test "Terminal: print writes to bottom if scrolled" { + var t = try init(testing.allocator, 5, 2); + defer t.deinit(testing.allocator); - // HT - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + // Basic grid writing + for ("hello") |c| try t.print(c); + t.setCursorPos(0, 0); - // HT at the end - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); -} + // Make newlines so we create scrollback + // 3 pushes hello off the screen + try t.index(); + try t.index(); + try t.index(); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } -test "Terminal: horizontal tabs starting on tabstop" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); + // Scroll to the top + try t.scrollViewport(.{ .top = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } - t.setCursorPos(t.screen.cursor.y, 9); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); - try t.horizontalTab(); + // Type try t.print('A'); - + try t.scrollViewport(.{ .bottom = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); + try testing.expectEqualStrings("\nA", str); } } -test "Terminal: horizontal tabs with right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); +// X +test "Terminal: print charset" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.setCursorPos(t.screen.cursor.y, 1); - try t.print('X'); - try t.horizontalTab(); - try t.print('A'); + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + // Basic grid writing + try t.print('`'); + t.configureCharset(.G0, .utf8); + try t.print('`'); + t.configureCharset(.G0, .ascii); + try t.print('`'); + t.configureCharset(.G0, .dec_special); + try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X A", str); + try testing.expectEqualStrings("```◆", str); } } -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); - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); +// X +test "Terminal: print charset outside of ASCII" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // Basic grid writing + t.configureCharset(.G0, .dec_special); + try t.print('`'); + try t.print(0x1F600); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆ ", str); + } } -test "Terminal: horizontal tabs back starting on tabstop" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); +// X +test "Terminal: print invoke charset" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - t.setCursorPos(t.screen.cursor.y, 9); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); - try t.horizontalTabBack(); - try t.print('A'); + t.configureCharset(.G1, .dec_special); + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, false); + try t.print('`'); + try t.print('`'); + t.invokeCharset(.GL, .G0, false); + try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A X", str); + try testing.expectEqualStrings("`◆◆`", str); } } -test "Terminal: horizontal tabs with left margin in origin mode" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); +// X +test "Terminal: print invoke charset single" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - t.modes.set(.origin, true); - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.setCursorPos(1, 2); - try t.print('X'); - try t.horizontalTabBack(); - try t.print('A'); + t.configureCharset(.G1, .dec_special); + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, true); + try t.print('`'); + try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" AX", str); + try testing.expectEqualStrings("`◆`", str); } } -test "Terminal: horizontal tab back with cursor before left margin" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); +// X +test "Terminal: print right margin wrap" { + var t = try init(testing.allocator, 10, 5); + defer t.deinit(testing.allocator); - t.modes.set(.origin, true); - t.saveCursor(); + try t.printString("123456789"); t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(5, 0); - try t.restoreCursor(); - try t.horizontalTabBack(); - try t.print('X'); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.printString("XY"); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); + try testing.expectEqualStrings("1234X6789\n Y", str); } } -test "Terminal: cursorPos resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); +// X +test "Terminal: print right margin outside" { + var t = try init(testing.allocator, 10, 5); + defer t.deinit(testing.allocator); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.setCursorPos(1, 1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 6); + try t.printString("XY"); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE", str); + try testing.expectEqualStrings("12345XY89", str); } } -test "Terminal: cursorPos off the screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); +// X +test "Terminal: print right margin outside wrap" { + var t = try init(testing.allocator, 10, 5); + defer t.deinit(testing.allocator); - t.setCursorPos(500, 500); - try t.print('X'); + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 10); + try t.printString("XY"); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n\n X", str); + try testing.expectEqualStrings("123456789X\n Y", str); } } -test "Terminal: cursorPos relative to origin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.modes.set(.origin, true); - t.setCursorPos(1, 1); - try t.print('X'); +// X +test "Terminal: linefeed and carriage return" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + // Basic grid writing + for ("hello") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("world") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), 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); - try testing.expectEqualStrings("\n\nX", str); + try testing.expectEqualStrings("hello\nworld", str); } } -test "Terminal: cursorPos relative to origin with left/right" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(1, 1); - try t.print('X'); +// X +test "Terminal: linefeed unsets pending wrap" { + var t = try init(testing.allocator, 5, 80); + defer t.deinit(testing.allocator); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n X", str); - } + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap == true); + try t.linefeed(); + try testing.expect(t.screen.cursor.pending_wrap == false); } -test "Terminal: cursorPos limits with full scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); +// X +test "Terminal: linefeed mode automatic carriage return" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(500, 500); + // Basic grid writing + t.modes.set(.linefeed, true); + try t.printString("123456"); + try t.linefeed(); try t.print('X'); - { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n X", str); + try testing.expectEqualStrings("123456\nX", str); } } -// Probably outdated, but dates back to the original terminal implementation. -test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, 80, 80); +// X +test "Terminal: carriage return unsets pending wrap" { + var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap == true); + t.carriageReturn(); + try testing.expect(t.screen.cursor.pending_wrap == false); +} - // Setting it to 0 should keep it zero (1 based) - t.setCursorPos(0, 0); +// X +test "Terminal: carriage return origin mode moves to left margin" { + var t = try init(testing.allocator, 5, 80); + defer t.deinit(testing.allocator); + + t.modes.set(.origin, true); + t.screen.cursor.x = 0; + t.scrolling_region.left = 2; + t.carriageReturn(); + 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); + + t.screen.cursor.x = 1; + t.scrolling_region.left = 2; + t.carriageReturn(); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} - // Should clamp to size - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); +// 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); - // Should reset pending wrap - t.setCursorPos(0, 80); - try t.print('c'); - try testing.expect(t.screen.cursor.pending_wrap); - t.setCursorPos(0, 80); - try testing.expect(!t.screen.cursor.pending_wrap); + t.screen.cursor.x = 3; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); +} - // Origin mode - t.modes.set(.origin, true); +// X +test "Terminal: backspace" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - // No change without a scroll region - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + // BS + for ("hello") |c| try t.print(c); + t.backspace(); + try t.print('y'); + 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); + try testing.expectEqualStrings("helly", str); + } +} - // Set the scroll region - t.setTopAndBottomMargin(10, t.rows); - t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); +// X +test "Terminal: horizontal tabs" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - t.setCursorPos(1, 1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + // HT + try t.print('1'); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); - t.setCursorPos(100, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + // HT + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - t.setTopAndBottomMargin(10, 11); - t.setCursorPos(2, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); + // HT at the end + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); } -test "Terminal: setTopAndBottomMargin simple" { +// X +test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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(0, 0); - t.scrollDown(1); + t.screen.cursor.x = 8; + try t.print('X'); + t.screen.cursor.x = 8; + try t.horizontalTab(); + try t.print('A'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings(" X A", str); } } -test "Terminal: setTopAndBottomMargin top only" { +// X +test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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, 0); - t.scrollDown(1); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.screen.cursor.x = 0; + try t.print('X'); + try t.horizontalTab(); + try t.print('A'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + try testing.expectEqualStrings("X A", str); } } -test "Terminal: setTopAndBottomMargin top and bottom" { +// X +test "Terminal: horizontal tabs back" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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(1, 2); - t.scrollDown(1); + // Edge of screen + t.screen.cursor.x = 19; + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nGHI", str); - } + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -test "Terminal: setTopAndBottomMargin top equal to bottom" { +// X +test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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, 2); - t.scrollDown(1); + t.screen.cursor.x = 8; + try t.print('X'); + t.screen.cursor.x = 8; + try t.horizontalTabBack(); + try t.print('A'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("A X", str); } } -test "Terminal: setLeftAndRightMargin simple" { +// X +test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(0, 0); - t.eraseChars(1); + t.modes.set(.origin, true); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.screen.cursor.x = 3; + try t.print('X'); + try t.horizontalTabBack(); + try t.print('A'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" BC\nDEF\nGHI", str); + try testing.expectEqualStrings(" AX", str); } } -test "Terminal: setLeftAndRightMargin left only" { +// X +test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 20, 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.modes.set(.origin, true); + t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 0); - 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); + t.setLeftAndRightMargin(5, 0); + t.restoreCursor(); + try t.horizontalTabBack(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + try testing.expectEqualStrings("X", str); } } -test "Terminal: setLeftAndRightMargin left and right" { +// X +test "Terminal: cursorPos resets wrap" { 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - t.insertLines(1); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(1, 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(" C\nABF\nDEI\nGH", str); + try testing.expectEqualStrings("XBCDE", str); } } -test "Terminal: setLeftAndRightMargin left equal right" { +// X +test "Terminal: cursorPos off the screen" { 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 2); - t.setCursorPos(1, 2); - t.insertLines(1); + t.setCursorPos(500, 500); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("\n\n\n\n X", str); } } -test "Terminal: setLeftAndRightMargin mode 69 unset" { +// X +test "Terminal: cursorPos relative to origin" { 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.modes.set(.enable_left_and_right_margin, false); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - t.insertLines(1); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("\n\nX", str); } } -test "Terminal: insertLines simple" { +// X +test "Terminal: cursorPos relative to origin with left/right" { 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); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + try testing.expectEqualStrings("\n\n X", str); } } -test "Terminal: insertLines colors with bg color" { +// X +test "Terminal: cursorPos limits with full 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.setCursorPos(2, 2); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.insertLines(1); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(500, 500); + try t.print('X'); { 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); + try testing.expectEqualStrings("\n\n\n X", str); } } -test "Terminal: insertLines handles style refs" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); +// X +test "Terminal: setCursorPos (original test)" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - // For the line being deleted, create a refcounted style - try t.setAttribute(.{ .bold = {} }); - try t.printString("GHI"); - try t.setAttribute(.{ .unset = {} }); + // Setting it to 0 should keep it zero (1 based) + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - // 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)); + // Should clamp to size + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - t.setCursorPos(2, 2); - t.insertLines(1); + // Should reset pending wrap + t.setCursorPos(0, 80); + try t.print('c'); + try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(0, 80); + try testing.expect(!t.screen.cursor.pending_wrap); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF", str); - } + // Origin mode + t.modes.set(.origin, true); - // verify we have no styles in our style map - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); -} + // No change without a scroll region + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); -test "Terminal: insertLines outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); + // Set the scroll region + t.setTopAndBottomMargin(10, t.rows); + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - 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); + t.setCursorPos(1, 1); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } + t.setCursorPos(100, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + + t.setTopAndBottomMargin(10, 11); + t.setCursorPos(2, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } -test "Terminal: insertLines top/bottom scroll region" { +// X +test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3754,204 +3349,163 @@ test "Terminal: insertLines top/bottom scroll region" { 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); + t.setTopAndBottomMargin(0, 0); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: insertLines (legacy test)" { +// X +test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 5, 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.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); + try t.printString("DEF"); t.carriageReturn(); try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert two lines - t.insertLines(2); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 0); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\nB\nC", str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", 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" { +// X +test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; - var t = try init(alloc, 2, 6); + var t = try init(alloc, 5, 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.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); + try t.printString("DEF"); t.carriageReturn(); try t.linefeed(); - try t.print('E'); - + try t.printString("GHI"); t.setTopAndBottomMargin(1, 2); - t.setCursorPos(1, 1); - t.insertLines(1); - - try t.print('X'); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + try testing.expectEqualStrings("\nABC\nGHI", str); } } -test "Terminal: insertLines more than remaining" { +// X +test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 5, 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.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); + try t.printString("DEF"); 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); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 2); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: insertLines resets wrap" { +// X +test "Terminal: setLeftAndRightMargin simple" { 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'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(0, 0); + t.eraseChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B\nABCDE", str); + try testing.expectEqualStrings(" BC\nDEF\nGHI", str); } } -test "Terminal: insertLines multi-codepoint graphemes" { +// X +test "Terminal: setLeftAndRightMargin left only" { 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); - + try t.printString("DEF"); t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.setCursorPos(2, 2); - t.insertLines(1); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 0); + 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); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); } } -test "Terminal: insertLines left/right scroll region" { +// X +test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); + try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF456"); + try t.printString("DEF"); 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); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 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); + try testing.expectEqualStrings(" C\nABF\nDEI\nGH", str); } } -test "Terminal: scrollUp simple" { +// X +test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3963,20 +3517,20 @@ test "Terminal: scrollUp simple" { 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); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 2); + t.setCursorPos(1, 2); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("DEF\nGHI", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: scrollUp top/bottom scroll region" { +// X +test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3988,129 +3542,178 @@ test "Terminal: scrollUp top/bottom scroll region" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - t.scrollUp(1); + t.modes.set(.enable_left_and_right_margin, false); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: scrollUp left/right scroll region" { +// X +test "Terminal: deleteLines" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - try t.printString("ABC123"); + // Initial value + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF456"); + try t.print('B'); 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); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.cursorUp(2); + try t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + try testing.expectEqualStrings("A\nE\nD", str); } } -test "Terminal: scrollUp preserves pending wrap" { +// X +test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - t.setCursorPos(1, 5); + // Initial value try t.print('A'); - t.setCursorPos(2, 5); + t.carriageReturn(); + try t.linefeed(); try t.print('B'); - t.setCursorPos(3, 5); + t.carriageReturn(); + try t.linefeed(); try t.print('C'); - t.scrollUp(1); - try t.print('X'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + try t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" B\n C\n\nX", str); + try testing.expectEqualStrings("E\nC\n\nD", str); } } -test "Terminal: scrollUp full top/bottom region" { +// X +test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.setTopAndBottomMargin(2, 5); - t.scrollUp(4); + // 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + try t.deleteLines(5); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("top", str); + try testing.expectEqualStrings("E\n\n\nD", str); } } -test "Terminal: scrollUp full top/bottomleft/right scroll region" { +// X +test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); 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); + // 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("top\n\n\n\nA E", str); + try testing.expectEqualStrings("A\nB\nC\nD", str); } } -test "Terminal: scrollDown simple" { +// X +test "Terminal: deleteLines resets wrap" { 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); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + try t.deleteLines(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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("B", str); } } -test "Terminal: scrollDown outside of scroll region" { +// X +test "Terminal: deleteLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -4122,21 +3725,18 @@ test "Terminal: scrollDown outside of scroll region" { 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); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + try testing.expectEqualStrings("ABC\nGHI", str); } } -test "Terminal: scrollDown left/right scroll region" { +// X +test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); @@ -4151,19 +3751,36 @@ test "Terminal: scrollDown left/right scroll region" { 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); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); } } -test "Terminal: scrollDown outside of 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); defer t.deinit(alloc); @@ -4177,270 +3794,273 @@ test "Terminal: scrollDown outside of left/right scroll region" { 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'); + t.setCursorPos(1, 2); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n A\n B\nX C", str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -test "Terminal: eraseChars simple operation" { +// X +test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseChars(2); - try t.print('X'); + 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.deleteLines(100); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X C", str); + try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); } } -test "Terminal: eraseChars minimum one" { +// X +test "Terminal: insertLines simple" { 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'); + 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("XBC", str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); } } -test "Terminal: eraseChars beyond screen edge" { +// X +test "Terminal: insertLines outside of scroll region" { 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); + 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(" A", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: eraseChars wide character" { +// 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.print('橋'); - for ("BC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseChars(1); - try t.print('X'); + 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("X BC", str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); } } -test "Terminal: eraseChars resets pending wrap" { +// X +test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); 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'); + 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("ABCDX", str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); } } -test "Terminal: eraseChars resets wrap" { +// X +test "Terminal: insertLines" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 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); + // 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'); - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - const row = list_cell.row; - try testing.expect(!row.wrap); - } + // Move to row 2 + t.setCursorPos(2, 1); - try t.print('X'); + // Insert two lines + try t.insertLines(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE\n123", str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); } } -test "Terminal: eraseChars preserves background sgr" { +// X +test "Terminal: insertLines zero" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 2, 5); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); + // This should do nothing 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); - } - } + try t.insertLines(0); } -test "Terminal: eraseChars handles refcounted styles" { +// X +test "Terminal: insertLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 2, 6); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); + // Initial value try t.print('A'); + t.carriageReturn(); + try t.linefeed(); try t.print('B'); - try t.setAttribute(.{ .unset = {} }); + 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'); - // 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.setTopAndBottomMargin(1, 2); 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); + try t.insertLines(1); - t.setProtectedMode(.iso); - 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("ABC", str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); } } -test "Terminal: eraseChars protected attributes ignored with dec most recent" { +// X +test "Terminal: insertLines more than remaining" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 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); + // 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(" C", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: eraseChars protected attributes ignored with dec set" { +// X +test "Terminal: insertLines resets wrap" { 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); + 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 str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings("B\nABCDE", str); } } +// X test "Terminal: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4454,7 +4074,7 @@ test "Terminal: reverseIndex" { t.carriageReturn(); try t.linefeed(); try t.print('C'); - t.reverseIndex(); + try t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); @@ -4468,6 +4088,7 @@ test "Terminal: reverseIndex" { } } +// X test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4483,13 +4104,13 @@ test "Terminal: reverseIndex from the top" { try t.linefeed(); t.setCursorPos(1, 1); - t.reverseIndex(); + try t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); t.setCursorPos(1, 1); - t.reverseIndex(); + try t.reverseIndex(); try t.print('E'); t.carriageReturn(); try t.linefeed(); @@ -4501,6 +4122,7 @@ test "Terminal: reverseIndex from the top" { } } +// X test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 10); @@ -4524,7 +4146,7 @@ test "Terminal: reverseIndex top of scrolling region" { // Set our scroll region t.setTopAndBottomMargin(2, 5); t.setCursorPos(2, 1); - t.reverseIndex(); + try t.reverseIndex(); try t.print('X'); { @@ -4534,6 +4156,7 @@ test "Terminal: reverseIndex top of scrolling region" { } } +// X test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4545,7 +4168,7 @@ test "Terminal: reverseIndex top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(1, 1); - t.reverseIndex(); + try t.reverseIndex(); try t.print('X'); { @@ -4555,6 +4178,7 @@ test "Terminal: reverseIndex top of screen" { } } +// X test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4566,7 +4190,7 @@ test "Terminal: reverseIndex not top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(2, 1); - t.reverseIndex(); + try t.reverseIndex(); try t.print('X'); { @@ -4576,6 +4200,7 @@ test "Terminal: reverseIndex not top of screen" { } } +// X test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4588,7 +4213,7 @@ test "Terminal: reverseIndex top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(2, 1); - t.reverseIndex(); + try t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4597,6 +4222,7 @@ test "Terminal: reverseIndex top/bottom margins" { } } +// X test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4609,7 +4235,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(1, 1); - t.reverseIndex(); + try t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4618,6 +4244,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { } } +// X test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4631,7 +4258,7 @@ test "Terminal: reverseIndex left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 2); - t.reverseIndex(); + try t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4640,6 +4267,7 @@ test "Terminal: reverseIndex left/right margins" { } } +// X test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4653,7 +4281,7 @@ test "Terminal: reverseIndex outside left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 1); - t.reverseIndex(); + try t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4662,6 +4290,7 @@ test "Terminal: reverseIndex outside left/right margins" { } } +// X test "Terminal: index" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4677,6 +4306,7 @@ test "Terminal: index" { } } +// X test "Terminal: index from the bottom" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4696,6 +4326,7 @@ test "Terminal: index from the bottom" { } } +// X test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4707,6 +4338,7 @@ test "Terminal: index outside of scrolling region" { 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); @@ -4725,6 +4357,7 @@ test "Terminal: index from the bottom outside of scroll region" { } } +// X test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4741,6 +4374,7 @@ test "Terminal: index no scroll region, top of screen" { } } +// X test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4758,18 +4392,19 @@ test "Terminal: index bottom of primary screen" { } } +// 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'); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + t.screen.cursor.pen = pen; try t.index(); { @@ -4777,17 +4412,13 @@ test "Terminal: index bottom of primary screen background sgr" { 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); + 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); @@ -4805,6 +4436,28 @@ test "Terminal: index inside scroll region" { } } +// 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); @@ -4826,6 +4479,7 @@ test "Terminal: index bottom of primary screen with scroll region" { } } +// X test "Terminal: index outside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -4847,6 +4501,7 @@ test "Terminal: index outside left/right margin" { } } +// X test "Terminal: index inside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -4875,2828 +4530,2950 @@ test "Terminal: index inside left/right margin" { } } -test "Terminal: index bottom of scroll region" { +// X +test "Terminal: DECALN" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 2); defer t.deinit(alloc); - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.print('B'); - t.setCursorPos(3, 1); + // Initial value 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.carriageReturn(); + try t.linefeed(); + try t.print('B'); + try t.decaln(); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(10); - try t.print('X'); + try testing.expectEqual(@as(usize, 0), 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(" X\n\nA", str); + try testing.expectEqualStrings("EE\nEE", str); } } -test "Terminal: cursorUp below top scroll margin" { +// X +test "Terminal: decaln reset margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 3); defer t.deinit(alloc); - t.setTopAndBottomMargin(2, 4); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(5); - try t.print('X'); + // Initial value + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X\nA", str); + try testing.expectEqualStrings("\nEEE\nEEE", str); } } -test "Terminal: cursorUp above top scroll margin" { +// X +test "Terminal: decaln preserves color" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 3); 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); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; - 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'); + // Initial value + t.screen.cursor.pen = pen; + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); } } -test "Terminal: cursorLeft no wrap" { +// X +test "Terminal: insertBlanks" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); try t.print('A'); - t.carriageReturn(); - try t.linefeed(); try t.print('B'); - t.cursorLeft(10); + try t.print('C'); + t.screen.cursor.pen.attrs.bold = true; + t.setCursorPos(1, 1); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB", str); + try testing.expectEqualStrings(" ABC", str); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expect(!cell.attrs.bold); } } -test "Terminal: cursorLeft unsets pending wrap state" { +// X +test "Terminal: insertBlanks pushes off end" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); 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'); + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCXE", str); + try testing.expectEqualStrings(" A", str); } } -test "Terminal: cursorLeft unsets pending wrap state with longer jump" { +// X +test "Terminal: insertBlanks more than size" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); 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'); + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(5); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AXCDE", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: cursorLeft reverse wrap with pending wrap state" { +// X +test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); 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'); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings(" ABC", str); } } -test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { +// X +test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; - 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'); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.screen.cursor.pen = pen; + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings(" ABC", str); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); } } -test "Terminal: cursorLeft reverse wrap" { +// X +test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 10); 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); + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(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); + try testing.expectEqualStrings(" X A", str); } } -test "Terminal: cursorLeft reverse wrap with no soft wrap" { +// X +test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 10); 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'); + for ("123") |c| try t.print(c); + try t.print('橋'); + t.setCursorPos(1, 1); + t.insertBlanks(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\nX", str); + try testing.expectEqualStrings(" 123", str); } } -test "Terminal: cursorLeft reverse wrap before left margin" { +// X +test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.setTopAndBottomMargin(3, 0); - t.cursorLeft(1); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 3); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); + try testing.expectEqualStrings(" X A", str); } } -test "Terminal: cursorLeft extended reverse wrap" { +// X +test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); 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); + t.setCursorPos(1, 4); + for ("ABC") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.insertBlanks(2); + 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\n1", str); + try testing.expectEqualStrings(" ABX", str); } } -test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { +// X +test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, 10, 10); 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); + t.modes.set(.origin, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 1); + t.insertBlanks(140); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: cursorLeft extended reverse wrap is priority if both set" { +// X +test "Terminal: insert mode with space" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, 10, 2); 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); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings("hXello", str); } } -test "Terminal: cursorLeft extended reverse wrap above top scroll region" { +// X +test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); 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); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXell", str); + } } -test "Terminal: cursorLeft reverse wrap on first row" { +// X +test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); 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); + for ("hello") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('X'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello\nX", str); + } } -test "Terminal: cursorDown basic" { +// X +test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\n X", str); + try testing.expectEqualStrings("h😀el", str); } } -test "Terminal: cursorDown above bottom scroll margin" { +// X +test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); + for ("well") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n X", str); + try testing.expectEqualStrings("well\n😀", str); } } -test "Terminal: cursorDown below bottom scroll margin" { +// X +test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.setCursorPos(4, 1); - t.cursorDown(10); + for ("123") |c| try t.print(c); + try t.print('😀'); // 0x1F600 + t.modes.set(.insert, true); + t.setCursorPos(1, 1); 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); + try testing.expectEqualStrings("X123", str); } } -test "Terminal: cursorDown resets wrap" { +// X +test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); 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'); + 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()); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n X", str); - } + // Until we know we're at a prompt again + try t.linefeed(); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); } -test "Terminal: cursorRight resets wrap" { +// X +test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); 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'); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } + // Secondary screen is never a prompt + t.alternateScreen(alloc, .{}); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(!t.cursorIsAtPrompt()); } -test "Terminal: cursorRight to the edge of screen" { +// X +test "Terminal: print wide char with 1-column width" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 1, 2); 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); - } + try t.print('😀'); // 0x1F600 } -test "Terminal: cursorRight left of right margin" { +// X +test "Terminal: deleteChars" { 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'); + 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); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("ADE", str); + + const cell = t.screen.getCell(.active, 0, 4); + try testing.expect(!cell.attrs.bold); } } -test "Terminal: cursorRight right of right margin" { +// X +test "Terminal: deleteChars zero count" { 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'); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("ABCDE", str); } } -test "Terminal: deleteLines simple" { +// X +test "Terminal: deleteChars more than half" { 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); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); + try testing.expectEqualStrings("AE", str); } } -test "Terminal: deleteLines colors with bg color" { +// X +test "Terminal: deleteChars more than line width" { 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); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.deleteChars(10); { 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); + try testing.expectEqualStrings("A", str); } } -test "Terminal: deleteLines (legacy)" { +// X +test "Terminal: deleteChars should shift left" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 5, 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.cursorUp(2); - t.deleteLines(1); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nE\nD", str); + try testing.expectEqualStrings("ACDE", str); } } -test "Terminal: deleteLines with scroll region" { +// X +test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 5, 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - t.deleteLines(1); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + try t.deleteChars(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("E\nC\n\nD", str); + try testing.expectEqualStrings("ABCDX", str); } } // X -test "Terminal: deleteLines with scroll region, large count" { +test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 10, 10); 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - t.deleteLines(5); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try t.printString("ABC123"); + t.setCursorPos(1, 3); + try t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("E\n\n\nD", str); + try testing.expectEqualStrings("AB23", str); } } // X -test "Terminal: deleteLines with scroll region, cursor outside of region" { +test "Terminal: deleteChars background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 10, 10); 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'); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - t.deleteLines(1); + try t.printString("ABC123"); + t.setCursorPos(1, 3); + t.screen.cursor.pen = pen; + try t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB\nC\nD", 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); + } } } -test "Terminal: deleteLines resets wrap" { +// X +test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + try t.deleteChars(2); try testing.expect(t.screen.cursor.pending_wrap); - t.deleteLines(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", str); + try testing.expectEqualStrings("ABC123", str); } } -test "Terminal: deleteLines left/right scroll region" { +// X +test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 6, 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.deleteLines(1); - + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 4); + try t.deleteChars(1); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); + try testing.expectEqualStrings("ABC2 3", str); } } -test "Terminal: deleteLines left/right scroll region from top" { +// X +test "Terminal: deleteChars split wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 6, 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, 2); - t.deleteLines(1); + try t.printString("A橋123"); + t.setCursorPos(1, 3); + try t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + try testing.expectEqualStrings("A 123", str); } } -test "Terminal: deleteLines left/right scroll region high count" { +// X +test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); + t.setCursorPos(1, t.cols - 1); + try t.print(0x6A4B); // 橋 t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - t.deleteLines(100); + try t.deleteChars(t.cols - 1); + try t.print('0'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); + try testing.expectEqualStrings("0", str); } } -test "Terminal: default style is empty" { +// X +test "Terminal: eraseChars resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); + 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 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("ABCDX", str); } } -test "Terminal: bold style" { +// X +test "Terminal: eraseChars 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'); + 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 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("XBCDE\n123", str); } } -test "Terminal: garbage collect overwritten" { +// X +test "Terminal: eraseChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); - try t.print('A'); + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - try t.setAttribute(.{ .unset = {} }); - try t.print('B'); + t.eraseChars(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, '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("X C", str); } - - // 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)); } -test "Terminal: do not garbage collect old styles in use" { +// X +test "Terminal: eraseChars minimum one" { 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 = {} }); - try t.print('B'); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(0); + try t.print('X'); { - 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("XBC", 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: eraseChars beyond screen edge" { 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 = {} }); - try t.print('B'); + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseChars(10); { - 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(" A", str); } } -test "Terminal: DECALN" { +// X +test "Terminal: eraseChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 2, 2); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - try t.decaln(); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + 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("EE\nEE", str); + try testing.expectEqualStrings(" C", str); + { + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); + } + { + const cell = t.screen.getCell(.active, 0, 1); + try testing.expectEqual(pen, cell); + } } } -test "Terminal: decaln reset margins" { +// X +test "Terminal: eraseChars wide character" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // Initial value - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - t.scrollDown(1); + 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("\nEEE\nEEE", str); + try testing.expectEqualStrings("X BC", str); } } -test "Terminal: decaln preserves color" { +// X +test "Terminal: eraseChars protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // Initial value - try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - t.scrollDown(1); + 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("\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); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: insertBlanks" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +// X +test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); - try t.print('B'); - try t.print('C'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); t.setCursorPos(1, 1); - t.insertBlanks(2); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: insertBlanks pushes off end" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +// X +test "Terminal: eraseChars protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); - try t.print('B'); - try t.print('C'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - t.insertBlanks(2); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: insertBlanks more than size" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +// 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, 2); + var t = try init(alloc, 3, 3); defer t.deinit(alloc); - try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(5); + try t.print('x'); + try t.print('😀'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('😀'); // 0x1F600 +} - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } +// 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); } -test "Terminal: insertBlanks no scroll region, fits" { +// X +test "Terminal: resize with wraparound on" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.insertBlanks(2); + 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(" ABC", str); - } + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); } -test "Terminal: insertBlanks preserves background sgr" { +// X +test "Terminal: saveCursor" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 3, 3); 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.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); - } + t.screen.cursor.pen.attrs.bold = true; + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + t.saveCursor(); + t.screen.charset.gr = .G0; + t.screen.cursor.pen.attrs.bold = false; + t.modes.set(.origin, false); + 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)); } -test "Terminal: insertBlanks shift off screen" { +// X +test "Terminal: saveCursor with screen change" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, 3, 3); defer t.deinit(alloc); - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); - } + t.screen.cursor.pen.attrs.bold = true; + t.screen.cursor.x = 2; + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + 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.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; + t.screen.cursor.pen.attrs.bold = false; + t.modes.set(.origin, false); + t.primaryScreen(alloc, .{ + .cursor_save = true, + .clear_on_enter = true, + }); + try testing.expect(t.screen.cursor.pen.attrs.bold); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); } -test "Terminal: insertBlanks split multi-cell character" { +// X +test "Terminal: saveCursor position" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('橋'); + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); t.setCursorPos(1, 1); - t.insertBlanks(1); + try t.print('B'); + t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" 123", str); + try testing.expectEqualStrings("B AX", str); } } -test "Terminal: insertBlanks inside left/right scroll region" { +// X +test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 3); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + t.restoreCursor(); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); + try testing.expectEqualStrings("B A\nX", str); } } -test "Terminal: insertBlanks outside left/right scroll region" { +// X +test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - t.setCursorPos(1, 4); - for ("ABC") |c| try t.print(c); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - t.insertBlanks(2); - try testing.expect(!t.screen.cursor.pending_wrap); + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setTopAndBottomMargin(2, 4); + t.restoreCursor(); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABX", str); + try testing.expectEqualStrings("X", str); } } -test "Terminal: insertBlanks left/right scroll region large count" { +// X +test "Terminal: saveCursor resize" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 1); - t.insertBlanks(140); + t.setCursorPos(1, 10); + t.saveCursor(); + try t.resize(alloc, 5, 5); + t.restoreCursor(); try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: insertBlanks deleting graphemes" { +// X +test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, true); + t.setProtectedMode(.iso); + try testing.expect(t.screen.cursor.pen.attrs.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 t.printString("ABC"); +// X +test "Terminal: setProtectedMode" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); - // 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); + try testing.expect(!t.screen.cursor.pen.attrs.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.pen.attrs.protected); + t.setProtectedMode(.iso); + try testing.expect(t.screen.cursor.pen.attrs.protected); + t.setProtectedMode(.dec); + try testing.expect(t.screen.cursor.pen.attrs.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.pen.attrs.protected); +} - // We should have one cell with graphemes - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.graphemeCount()); +// X +test "Terminal: eraseLine simple erase right" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - t.setCursorPos(1, 1); - t.insertBlanks(4); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings("AB", str); } - - // We should have no graphemes - try testing.expectEqual(@as(usize, 0), page.graphemeCount()); } -test "Terminal: insertBlanks shift graphemes" { +// X +test "Terminal: eraseLine resets pending wrap" { 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); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.right, false); + 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(" A👨‍👩‍👧", str); + try testing.expectEqualStrings("ABCDB", str); } - - // We should have no graphemes - try testing.expectEqual(@as(usize, 1), page.graphemeCount()); } -test "Terminal: insert mode with space" { +// X +test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('X'); - + for ("ABCDE123") |c| try t.print(c); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hXello", str); + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(row.isWrapped()); } -} -test "Terminal: insert mode doesn't wrap pushed characters" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); + t.setCursorPos(1, 1); + t.eraseLine(.right, false); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); + { + 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("hXell", str); + try testing.expectEqualStrings("X\n123", str); } } -test "Terminal: insert mode does nothing at the end of the line" { +// X +test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('X'); + 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; + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hello\nX", str); + try testing.expectEqualStrings("A", str); + for (1..5) |x| { + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); + } } } -test "Terminal: insert mode with wide characters" { +// X +test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("h😀el", str); + try testing.expectEqualStrings("AB", str); } } -test "Terminal: insert mode with wide characters at end" { +// X +test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("well") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("well\n😀", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: insert mode pushing off wide character" { +// X +test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('😀'); // 0x1F600 - t.modes.set(.insert, true); - t.setCursorPos(1, 1); - try t.print('X'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X123", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: deleteChars" { +// X +test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 2); + t.eraseLine(.right, false); - t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ADE", str); + try testing.expectEqualStrings("A", str); } } -test "Terminal: deleteChars zero count" { +// X +test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + for ("12345678") |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.eraseLine(.right, true); - t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE", str); + try testing.expectEqualStrings("123 X", str); } } -test "Terminal: deleteChars more than half" { +// X +test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); - t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AE", str); + try testing.expectEqualStrings(" DE", str); } } -test "Terminal: deleteChars more than line width" { +// X +test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.left, false); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('B'); - t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings(" B", str); } } -test "Terminal: deleteChars should shift left" { +// 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; + t.eraseLine(.left, false); - t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ACDE", str); + try testing.expectEqualStrings(" CDE", str); + for (0..2) |x| { + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); + } } } -test "Terminal: deleteChars resets wrap" { +// X +test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.deleteChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings(" DE", str); } } -test "Terminal: deleteChars simple operation" { +// X +test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.setCursorPos(1, 3); - t.deleteChars(2); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB23", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: deleteChars preserves background sgr" { +// X +test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABC123") |c| try t.print(c); - t.setCursorPos(1, 3); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.deleteChars(2); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { 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); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: deleteChars outside scroll region" { +// X +test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - t.deleteChars(2); - try testing.expect(t.screen.cursor.pending_wrap); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123", str); + try testing.expectEqualStrings(" C", str); } } -test "Terminal: deleteChars inside scroll region" { +// X +test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 4); - t.deleteChars(1); + 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.eraseLine(.left, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC2 3", str); + try testing.expectEqualStrings(" X 9", str); } } -test "Terminal: deleteChars split wide character" { +// X +test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("A橋123"); - t.setCursorPos(1, 3); - t.deleteChars(1); + 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; + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A 123", str); + try testing.expectEqualStrings("", str); + for (0..5) |x| { + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); + } } } -test "Terminal: deleteChars split wide character tail" { +// X +test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setCursorPos(1, t.cols - 1); - try t.print(0x6A4B); // 橋 - t.carriageReturn(); - t.deleteChars(t.cols - 1); - try t.print('0'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("0", str); + try testing.expectEqualStrings("ABC", str); } } -test "Terminal: saveCursor" { +// X +test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.saveCursor(); - t.screen.charset.gr = .G0; - try t.setAttribute(.{ .unset = {} }); - t.modes.set(.origin, false); - 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)); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } } -test "Terminal: saveCursor with screen change" { +// X +test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); - t.screen.cursor.x = 2; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - 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.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; - try t.setAttribute(.{ .reset_bold = {} }); - t.modes.set(.origin, false); - t.primaryScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } } -test "Terminal: saveCursor position" { +// X +test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); defer t.deinit(alloc); - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - try t.restoreCursor(); + 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.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B AX", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: saveCursor pending wrap state" { +// X +test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - try t.restoreCursor(); - try t.print('X'); + 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("B A\nX", str); + try testing.expectEqualStrings("ABC\nD", str); } } -test "Terminal: saveCursor origin mode" { +// X +test "Terminal: eraseDisplay erase below preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.saveCursor(); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setTopAndBottomMargin(2, 4); - try t.restoreCursor(); - try t.print('X'); + 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("X", str); + try testing.expectEqualStrings("ABC\nD", str); + for (1..5) |x| { + const cell = t.screen.getCell(.active, 1, x); + try testing.expectEqual(pen, cell); + } } } -test "Terminal: saveCursor resize" { +// X +test "Terminal: eraseDisplay below split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setCursorPos(1, 10); - t.saveCursor(); - try t.resize(alloc, 5, 5); - try t.restoreCursor(); - try t.print('X'); + 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(" X", str); + try testing.expectEqualStrings("AB橋C\nDE", str); } } -test "Terminal: saveCursor protected pen" { +// X +test "Terminal: eraseDisplay below protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.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); + 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); + } } -test "Terminal: setProtectedMode" { +// X +test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.protected); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + 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); - try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + 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); + } } -test "Terminal: eraseLine simple erase right" { +// 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); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.right, false); + 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("AB", str); + try testing.expectEqualStrings("ABC\nD", str); } } -test "Terminal: eraseLine resets pending wrap" { +// X +test "Terminal: eraseDisplay simple erase above" { 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.eraseLine(.right, false); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + 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("ABCDB", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: eraseLine resets wrap" { +// 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); - for ("ABCDE123") |c| try t.print(c); + 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 list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.wrap); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } +} - t.setCursorPos(1, 1); - t.eraseLine(.right, false); +// 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 list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(!list_cell.row.wrap); + 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); + } } - try t.print('X'); +} + +// 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("X\n123", str); + try testing.expectEqualStrings("\n F\nGH橋I", str); } } -test "Terminal: eraseLine right preserves background sgr" { +// 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); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseLine(.right, false); + 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("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); - } + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: eraseLine right wide character" { +// X +test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseLine(.right, false); + 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("AB", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: eraseLine right protected attributes respected with iso" { +// 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(.iso); + t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.right, false); + 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", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -test "Terminal: eraseLine right protected attributes ignored with dec most recent" { +// 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(.iso); - for ("ABC") |c| try t.print(c); t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(1, 2); - t.eraseLine(.right, false); + 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("A", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: eraseLine right protected attributes ignored with dec set" { +// 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, 5, 5); + 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); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.right, false); + 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("A", str); + try testing.expectEqualStrings("\n X", str); } } -test "Terminal: eraseLine right protected requested" { +// X +test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("12345678") |c| try t.print(c); + 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.eraseLine(.right, true); + t.eraseDisplay(alloc, .below, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("123 X", str); + try testing.expectEqualStrings("A\n123 X", str); } } -test "Terminal: eraseLine simple erase left" { +// X +test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); + 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(" DE", str); + try testing.expectEqualStrings("", str); } } -test "Terminal: eraseLine left resets wrap" { +// X +test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 3); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.eraseLine(.left, false); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + 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(" B", str); + try testing.expectEqualStrings("\n X 9", str); } } -test "Terminal: eraseLine left preserves background sgr" { +// X +test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseLine(.left, false); + 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(" 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); - } + try testing.expectEqualStrings("A\nB", str); } } -test "Terminal: eraseLine left wide character" { +// X +test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); + 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(" DE", str); + try testing.expectEqualStrings("ABCXE", str); } } -test "Terminal: eraseLine left protected attributes respected with iso" { +// 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); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.left, false); + 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("ABC", str); + try testing.expectEqualStrings("AXCDE", str); } } -test "Terminal: eraseLine left protected attributes ignored with dec most recent" { +// 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.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(1, 2); - t.eraseLine(.left, false); + 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(" C", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: eraseLine left protected attributes ignored with dec set" { +// 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.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.left, false); + 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(" C", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: eraseLine left protected requested" { +// X +test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); + 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'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseLine(.left, true); + try testing.expect(t.screen.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X 9", str); + try testing.expectEqualStrings("ABCDX\n1", str); } } -test "Terminal: eraseLine complete preserves background sgr" { +// 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.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseLine(.complete, false); + 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("", 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); - } + try testing.expectEqualStrings("ABCDE\nX", str); } } -test "Terminal: eraseLine complete protected attributes respected with iso" { +// 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.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.complete, false); + 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("ABC", str); + try testing.expectEqualStrings("\n\nX", str); } } -test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { +// X +test "Terminal: cursorLeft extended reverse wrap" { 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, 2); - t.eraseLine(.complete, false); + 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("", str); + try testing.expectEqualStrings("ABCDX\n1", str); } } -test "Terminal: eraseLine complete protected attributes ignored with dec set" { +// X +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 3); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.complete, false); + 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("", str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); } } -test "Terminal: eraseLine complete protected requested" { +// X +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 3); defer t.deinit(alloc); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); + 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'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); } } -test "Terminal: tabClear single" { +// X +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 30, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.horizontalTab(); - t.tabClear(.current); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); -} + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); -test "Terminal: tabClear all" { - const alloc = testing.allocator; - var t = try init(alloc, 30, 5); - defer t.deinit(alloc); + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(2, 1); + t.cursorLeft(1000); - t.tabClear(.all); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); } -test "Terminal: printRepeat simple" { +// X +test "Terminal: cursorLeft reverse wrap on first row" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("A"); - try t.printRepeat(1); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AA", str); - } + 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: printRepeat wrap" { +// X +test "Terminal: cursorDown basic" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString(" A"); - try t.printRepeat(1); + 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\nA", str); + try testing.expectEqualStrings("A\n\n\n\n X", str); } } -test "Terminal: printRepeat no previous character" { +// X +test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printRepeat(1); + 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("", str); + try testing.expectEqualStrings("A\n\n X", str); } } -test "Terminal: printAttributes" { +// X +test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - var storage: [64]u8 = undefined; - - { - try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;38:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.faint); - try t.setAttribute(.italic); - try t.setAttribute(.{ .underline = .single }); - try t.setAttribute(.blink); - try t.setAttribute(.inverse); - try t.setAttribute(.invisible); - try t.setAttribute(.strikethrough); - try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); - } - - { - try t.setAttribute(.{ .underline = .single }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;4", buf); - } + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); + try t.print('X'); { - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0", buf); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\nX", str); } } -test "Terminal: eraseDisplay simple erase below" { +// X +test "Terminal: cursorDown resets wrap" { 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(.below, false); + 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("ABC\nD", str); + try testing.expectEqualStrings("ABCDE\n X", str); } } -test "Terminal: eraseDisplay erase below preserves SGR bg" { +// X +test "Terminal: cursorUp basic" { 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); + 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("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); - } + try testing.expectEqualStrings(" X\n\nA", str); } } -test "Terminal: eraseDisplay below split multi-cell" { +// X +test "Terminal: cursorUp below top scroll margin" { 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); + 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("AB橋C\nDE", str); + try testing.expectEqualStrings("\n X\nA", str); } } -test "Terminal: eraseDisplay below protected attributes respected with iso" { +// X +test "Terminal: cursorUp above top scroll margin" { 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); + 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("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("X\n\nA", str); } } -test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { +// X +test "Terminal: cursorUp resets wrap" { 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); + 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("ABC\nD", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: eraseDisplay below protected attributes ignored with dec set" { +// X +test "Terminal: cursorRight resets wrap" { 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); + 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("ABC\nD", str); + try testing.expectEqualStrings("ABCDX", str); } } -test "Terminal: eraseDisplay below protected attributes respected with force" { +// 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.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); + t.cursorRight(100); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: eraseDisplay simple erase above" { +// X +test "Terminal: cursorRight left of right margin" { 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); + 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("\n F\nGHI", str); + try testing.expectEqualStrings(" X", str); } } -test "Terminal: eraseDisplay erase above preserves SGR bg" { +// X +test "Terminal: cursorRight right of right margin" { 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); + 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("\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); - } + try testing.expectEqualStrings(" X", str); } } -test "Terminal: eraseDisplay above split multi-cell" { +// X +test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("AB橋C"); + try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.printString("DE橋F"); + try t.printString("DEF"); t.carriageReturn(); try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 3); - t.eraseDisplay(.above, false); + 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("\n F\nGH橋I", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -test "Terminal: eraseDisplay above protected attributes respected with iso" { +// X +test "Terminal: scrollDown outside of scroll region" { 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); + try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - for ("DEF") |c| try t.print(c); + try t.printString("DEF"); t.carriageReturn(); try t.linefeed(); - for ("GHI") |c| try t.print(c); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); + 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\nGHI", str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); } } -test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { +// X +test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); + try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - for ("DEF") |c| try t.print(c); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); + 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("\n F\nGHI", str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); } } -test "Terminal: eraseDisplay above protected attributes ignored with dec set" { +// X +test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); + try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - for ("DEF") |c| try t.print(c); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); + 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("\n F\nGHI", str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); } } -test "Terminal: eraseDisplay above protected attributes respected with force" { +// X +test "Terminal: scrollDown preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 10); 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); + 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("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("\n A\n B\nX C", str); } } -test "Terminal: eraseDisplay protected complete" { +// X +test "Terminal: scrollUp simple" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); + try t.printString("ABC"); 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); + 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("\n X", str); + try testing.expectEqualStrings("DEF\nGHI", str); } } -test "Terminal: eraseDisplay protected below" { +// X +test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); + try t.printString("ABC"); 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); + 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("A\n123 X", str); + try testing.expectEqualStrings("ABC\nGHI", str); } } -test "Terminal: eraseDisplay scroll complete" { +// X +test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - try t.print('A'); + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - t.eraseDisplay(.scroll_complete, false); + 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("", str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -test "Terminal: eraseDisplay protected above" { +// X +test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); + t.setCursorPos(1, 5); 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); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + try t.scrollUp(1); 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); + try testing.expectEqualStrings(" B\n C\n\nX", str); } } -test "Terminal: cursorIsAtPrompt" { +// X +test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 5); 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()); + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.setTopAndBottomMargin(2, 5); + try t.scrollUp(4); - // Until we know we're at a prompt again - try t.linefeed(); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top", str); + } } -test "Terminal: cursorIsAtPrompt alternate screen" { +// X +test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 5); 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(); + 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 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); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); } } -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); +// X +test "Terminal: tabClear single" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 5); + defer t.deinit(alloc); - t.status_display = .status_line; - t.fullReset(); - try testing.expect(t.status_display == .main); + try t.horizontalTab(); + t.tabClear(.current); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.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" { +// X +test "Terminal: tabClear all" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 30, 5); 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 + t.tabClear(.all); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 29), t.screen.cursor.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" { +// X +test "Terminal: printRepeat simple" { const alloc = testing.allocator; - const cols = 70; - const rows = 23; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 5, 5); 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); + try t.printString("A"); + try t.printRepeat(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AA", str); + } } -// https://github.com/mitchellh/ghostty/issues/1343 -test "Terminal: resize with wraparound off" { +// X +test "Terminal: printRepeat wrap" { const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 5, 5); 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); + try t.printString(" A"); + try t.printRepeat(1); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01", str); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A\nA", str); + } } -test "Terminal: resize with wraparound on" { +// X +test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 5, 5); 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); + try t.printRepeat(1); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01\n23", str); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } } +// X test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7709,6 +7486,7 @@ test "Terminal: DECCOLM without DEC mode 40" { try testing.expect(!t.modes.get(.@"132_column")); } +// X test "Terminal: DECCOLM unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7720,6 +7498,7 @@ test "Terminal: DECCOLM unset" { 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); @@ -7735,30 +7514,27 @@ test "Terminal: DECCOLM resets pending wrap" { 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); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + 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 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: DECCOLM resets scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7777,3 +7553,80 @@ test "Terminal: DECCOLM resets scroll region" { 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); + defer t.deinit(alloc); + + var storage: [64]u8 = undefined; + + { + try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;38:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.faint); + try t.setAttribute(.italic); + try t.setAttribute(.{ .underline = .single }); + try t.setAttribute(.blink); + try t.setAttribute(.inverse); + try t.setAttribute(.invisible); + try t.setAttribute(.strikethrough); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + } + + { + try t.setAttribute(.{ .underline = .single }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;4", buf); + } + + { + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0", buf); + } +} + +test "Terminal: preserve grapheme cluster on large scrollback" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + // 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.screen.testString(alloc, .viewport); + defer testing.allocator.free(str); + try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); + } +} 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 8f9a1b6668..d84ea91d61 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 bde44074b0..6e4efc55be 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 25a97cb2e0..e886d97c1f 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 0000000000..8c694f992c --- /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 385ce1eba1..694d5dfc0e 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 charsets = @import("charsets.zig"); const kitty = @import("kitty.zig"); -const point = @import("point.zig"); -const CircBuf = @import("../circ_buf.zig").CircBuf; +const sgr = @import("sgr.zig"); +const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); -const StringMap = @import("StringMap.zig"); -const fastmem = @import("../fastmem.zig"); -const charsets = @import("charsets.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 = .{}, -const log = std.log.scoped(.screen); +/// 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, +/// 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(); - /// 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, + // 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(); - /// pen is the current cell styling to apply to new cells. - pen: Cell = .{ .char = 0 }, + 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, + }, + }; +} - /// The last column flag (LCF) used to do soft wrapping. - pending_wrap: bool = false, +pub fn deinit(self: *Screen) void { + self.kitty_images.deinit(self.alloc, self); + self.pages.deinit(); +} - /// 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, - }; -}; +/// 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); +} -/// 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)); - } - } -}; +/// 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(); -/// 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; - } - }; -}; + return .{ + .alloc = alloc, + .pages = pages, + .no_scrollback = self.no_scrollback, -/// 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, - }, - }; - } -}; + // TODO: let's make this reasonble + .cursor = undefined, + }; +} -/// 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; - } - } = .{}, +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); +} - /// 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)), - } }); +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); +} - // 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; - } +pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { + assert(self.cursor.y > 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; + 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; +} - // 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; +/// 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); - return 1; - } + 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; +} - test "widthLegacy" { - const testing = std.testing; +/// Move the cursor left. +pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.x >= n); - var c: Cell = .{}; - try testing.expectEqual(@as(u16, 1), c.widthLegacy()); + 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; +} - c = .{ .attrs = .{ .wide = true } }; - try testing.expectEqual(@as(u16, 2), c.widthLegacy()); +/// 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); - c = .{ .attrs = .{ .wide_spacer_tail = true } }; - try testing.expectEqual(@as(u16, 0), c.widthLegacy()); - } + 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; +} - 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))); - } +pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { + assert(self.cursor.y >= n); - test { - //log.warn("CELL={} bits={} {}", .{ @sizeOf(Cell), @bitSizeOf(Cell), @alignOf(Cell) }); - try std.testing.expectEqual(20, @sizeOf(Cell)); - } -}; + const page_pin = self.cursor.page_pin.up(n).?; + const page_rac = page_pin.rowAndCell(); + return page_rac.row; +} -/// A row is a single row in the screen. -pub const Row = struct { - /// The screen this row is part of. - screen: *Screen, +/// 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); - /// 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, + // 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; - /// 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; - } + // Y of course increases + self.cursor.y += n; +} - /// 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; - } +/// Move the cursor to some absolute horizontal position. +pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { + assert(x < self.pages.cols); - /// 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; - } + 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; +} - pub inline fn isDirty(self: Row) bool { - return self.storage[0].header.flags.dirty; - } +/// 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); - pub inline fn isWrapped(self: Row) bool { - return self.storage[0].header.flags.wrap; - } + 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; +} - /// 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; - } +/// 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).?; + }; - /// Retrieve the semantic prompt state for this row. - pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt { - return self.storage[0].header.flags.semantic_prompt; + 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), + ); + } } - /// Retrieve the header for this row. - pub fn header(self: Row) RowHeader { - return self.storage[0].header; + // 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); + } } +} - /// Returns the number of cells in this row. - pub fn lenCells(self: Row) usize { - return self.storage.len - 1; +/// 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(); } +} - /// 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; - } +/// 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, +}; - return true; - } +/// 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; - /// 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); + switch (behavior) { + .active => self.pages.scroll(.{ .active = {} }), + .top => self.pages.scroll(.{ .top = {} }), + .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), } +} - /// 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); - } +/// 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(); - /// 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 + // 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; +} - // Always mark the row as dirty for this. - self.storage[0].header.flags.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; +} - // 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; - } +/// 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); + } - // 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 }; + // Reset our row to point to the proper memory but everything + // else is zeroed. + row.* = .{ .cells = cells_offset }; } + } +} - // 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; +/// 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); } } - /// 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; - } + if (row.styled) { + for (cells) |*cell| { + if (cell.style_id == style.default_id) continue; - /// 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); + // 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; + } - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; + // 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); + } + } - return &self.storage[x + 1].cell; + // 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; } - /// Attach a grapheme codepoint to the given cell. - pub fn attachGrapheme(self: Row, x: usize, cp: u21) !void { - assert(x < self.storage.len - 1); + @memset(cells, self.blankCell()); +} - 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); - }; +/// 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]); + } +} - // Our row now has a grapheme - self.storage[0].header.flags.grapheme = true; +/// 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 .{}; +} - // Our row is now dirty - self.storage[0].header.flags.dirty = true; +/// 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); +} - // 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; - } +/// 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); +} - // 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; - } +/// 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; - // We have an existing value, promote - assert(cell.attrs.grapheme); - try gop.value_ptr.append(self.screen.alloc, cp); - } + // 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 }, + }); - /// Removes all graphemes associated with a cell. - pub fn clearGraphemes(self: Row, x: usize) void { - assert(x < self.storage.len - 1); + // 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); + } - // Our row is now dirty - self.storage[0].header.flags.dirty = true; + // If our cursor was updated, we do a full reload so all our cursor + // state is correct. + self.cursorReload(); +} - 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); - } - } +/// 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 = .{}; + }, - /// 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); + .bold => { + self.cursor.style.flags.bold = true; + }, - // If our destination has graphemes, we have to clear them. - if (dst_cell.attrs.grapheme) self.clearGraphemes(x); - dst_cell.* = src_cell.*; + .reset_bold => { + // Bold and faint share the same SGR code for this + self.cursor.style.flags.bold = false; + self.cursor.style.flags.faint = false; + }, - // If the source doesn't have any graphemes, then we can just copy. - if (!src_cell.attrs.grapheme) return; + .italic => { + self.cursor.style.flags.italic = true; + }, - // 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; - } + .reset_italic => { + self.cursor.style.flags.italic = false; + }, - /// 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(.{}); + .faint => { + self.cursor.style.flags.faint = true; + }, - // Copy the flags - self.storage[0].header.flags = src.storage[0].header.flags; + .underline => |v| { + self.cursor.style.flags.underline = v; + }, - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; + .reset_underline => { + self.cursor.style.flags.underline = .none; + }, - // 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; - } + .underline_color => |rgb| { + self.cursor.style.underline_color = .{ .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, - // Source has graphemes, this is slow. - for (src.storage[1..end], 0..) |storage, x| { - self.storage[x + 1] = .{ .cell = storage.cell }; + .@"256_underline_color" => |idx| { + self.cursor.style.underline_color = .{ .palette = idx }; + }, - // 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; + .reset_underline_color => { + self.cursor.style.underline_color = .none; + }, - 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); + .blink => { + self.cursor.style.flags.blink = true; + }, - self.storage[0].header.flags.grapheme = true; - } - } - } + .reset_blink => { + self.cursor.style.flags.blink = false; + }, - /// Read-only iterator for the cells in the row. - pub fn cellIterator(self: Row) CellIterator { - return .{ .row = self }; - } + .inverse => { + self.cursor.style.flags.inverse = true; + }, - /// 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; - } + .reset_inverse => { + self.cursor.style.flags.inverse = false; + }, - /// 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 = {} } }; + .invisible => { + self.cursor.style.flags.invisible = true; + }, - 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 }; - } + .reset_invisible => { + self.cursor.style.flags.invisible = false; + }, - /// 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; + .strikethrough => { + self.cursor.style.flags.strikethrough = true; + }, - // 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; + .reset_strikethrough => { + self.cursor.style.flags.strikethrough = 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); - } + .direct_color_fg => |rgb| { + self.cursor.style.fg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, - // 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); - } + .direct_color_bg => |rgb| { + self.cursor.style.bg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, - return true; - } -}; + .@"8_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, -/// Used to iterate through the rows of a specific region. -pub const RowIterator = struct { - screen: *Screen, - tag: RowIndexTag, - max: usize, - value: usize = 0, + .@"8_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, - 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; - } -}; + .reset_fg => self.cursor.style.fg_color = .none, -/// Used to iterate through the rows of a specific region. -pub const CellIterator = struct { - row: Row, - i: usize = 0, + .reset_bg => self.cursor.style.bg_color = .none, - 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; - } -}; + .@"8_bright_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, -/// 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, - } - } + .@"8_bright_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, - pub fn next(self: *CodepointIterator) ?u21 { - switch (self.data) { - .zero => return null, + .@"256_fg" => |idx| { + self.cursor.style.fg_color = .{ .palette = idx }; + }, - .one => |v| { - if (self.i >= 1) return null; - self.i += 1; - return v; - }, + .@"256_bg" => |idx| { + self.cursor.style.bg_color = .{ .palette = idx }; + }, - .two => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + .unknown => return, + } - .three => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + try self.manualStyleUpdate(); +} - .four => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, +/// Call this whenever you manually change the cursor style. +pub fn manualStyleUpdate(self: *Screen) !void { + var page = &self.cursor.page_pin.page.data; - .many => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, + // 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); } } - pub fn reset(self: *CodepointIterator) void { - self.i = 0; + // 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; } -}; - -/// 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; - }, + // 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; +} - .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( +/// 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, - 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, - }; -} + 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(); -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; + 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; }; - - // 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); + 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; + }, } - - 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), + break :end end; }; -} - -/// 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; - }; + 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); - // 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; - } + 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, + } - break :end_y self.rowsWritten() - 1; - }; + 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]); + } + } + } - return .{ - .screen = self, - .tag = .screen, - .start = start_y, - .len = (end_y - start_y) + 1, - }; -} + if (row_count < rows.len - 1 and + (!row.wrap or sel_ordered.rectangle)) + { + try strbuilder.append('\n'); + } -/// 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(.{}); + row_count += 1; } } - 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); - } + // 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'); - { - // 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 }, - } }; - } + // 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'); } - 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 }, - } }; + // Remove all trailing newlines + for (0..strbuilder.items.len) |_| { + if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; + strbuilder.items.len -= 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); -} - -/// 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; - } - }; + // Get our final string + const string = try strbuilder.toOwnedSliceSentinel(0); + errdefer alloc.free(string); - 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; - - var y: usize = pt.y - 1; - while (true) { - const current = self.getRow(.{ .screen = y }); - if (!current.header().flags.wrap) break :start_row y + 1; + 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 = 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; + if (!row.wrap) { + var copy = p; + copy.x = p.page.data.size.cols - 1; + break :end_pin copy; + } } - unreachable; + + return null; }; // 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 }; - } + 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; } - // 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; + 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; } - // 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; + }; + + 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 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 }); - - // 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; + 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; } - // 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; - - 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; + 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'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; + } + + // 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; + } } - break :boundary .{ .x = self.cols - 1, .y = y_max }; + // 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 }; - }; - return Selection{ - .start = start, - .end = end, + break :boundary it_prev; }; + + 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, - - // 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, + 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; } - } 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 => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end y - 1, + 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; } - } 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,5910 +1408,4129 @@ 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; +/// 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; - switch (behavior) { - // Setting viewport offset to zero makes row 0 be at self.top - // which is the top! - .top => self.viewport = 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]; + }; - // Bottom is the end of the history area (end of history is the - // top of the active area). - .bottom => self.viewport = self.history, + 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: deltas greater than the entire scrollback - .screen => |delta| try self.scrollDelta(delta, false), - .viewport => |delta| try self.scrollDelta(delta, true), + // TODO: handle wrap + blank_rows += 1; - // Scroll to a specific row - .row => |idx| self.scrollRow(idx), + var blank_cells: usize = 0; + for (cells) |*cell| { + // Skip spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } - // Scroll until the viewport is clear by moving the viewport contents - // into the scrollback. - .clear => try self.scrollClear(), - } -} + // 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; + } -fn scrollClear(self: *Screen) Allocator.Error!void { - // The full amount of rows in the viewport - const full_amount = self.rowsWritten() - self.viewport; + switch (cell.content_tag) { + .codepoint => { + try writer.print("{u}", .{cell.content.codepoint}); + }, - // 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; + .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}); + } + }, - try self.scroll(.{ .screen = @intCast(non_empty) }); + else => unreachable, + } + } + } } -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)); +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(); } -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; +/// 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; + } - // 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; - } + 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), + } - // 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; - } + break :cell cell; + }; - // 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), + try self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + cell, + c, ); + continue; + } - // Allocate what we can. - try self.storage.resize( - self.alloc, - @min(max_capacity, needed_capacity), - ); + 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; } - } - // 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(); + 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, + }; - // 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(.{}); - } - } + // 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; + } - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - break :deleted rows_to_delete; - } else 0; + // Write our wide char + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.cursor.style_id, + .wide = .wide, + }; - // 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; + // Write our tail + self.cursorRight(1); + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + }, - // 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; + else => unreachable, + } - // If the selection is now empty, just clear it. - if (sel.empty()) self.selection = null; + if (self.cursor.x + 1 < self.pages.cols) { + self.cursorRight(1); + } else { + self.cursor.pending_wrap = true; + } } +} - // 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; - } +test "Screen read and write" { + const testing = std.testing; + const alloc = testing.allocator; - // 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, -}; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); -/// Jump the viewport to specific location. -pub fn jump(self: *Screen, target: JumpTarget) bool { - return switch (target) { - .prompt_delta => |delta| self.jumpPrompt(delta), - }; + try s.testWriteString("hello, world"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello, world", str); } -/// 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; +test "Screen read and write newline" { + const testing = std.testing; + const alloc = testing.allocator; - // 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); - }; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - // 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 => {}, - } - } + try s.testWriteString("hello\nworld"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld", str); +} - //log.warn("delta={} delta_rem={} start_y={} y={}", .{ delta, delta_rem, start_y, y }); +test "Screen read and write scrollback" { + const testing = std.testing; + const alloc = testing.allocator; - // If we didn't find any, do nothing. - if (delta_rem == delta_start) return false; + var s = try Screen.init(alloc, 80, 2, 1000); + defer s.deinit(); - // 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; + 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); + } } -/// 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); +test "Screen read and write no scrollback small" { + const testing = std.testing; + const alloc = testing.allocator; - // 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 s = try Screen.init(alloc, 80, 2, 0); + defer s.deinit(); - // Get our string result. - try self.selectionSliceString(slices, &strbuilder, null); + 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); + } +} - // 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'); +test "Screen read and write no scrollback large" { + const testing = std.testing; + const alloc = testing.allocator; - // 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'); - } + var s = try Screen.init(alloc, 80, 2, 0); + defer s.deinit(); - // Remove our trailing newline again - if (strbuilder.items.len > 0) strbuilder.items.len -= 1; + 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"); - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - - return string; + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("999\n1000", str); + } } -/// 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); +test "Screen style basics" { + const testing = std.testing; + const alloc = testing.allocator; - // 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(); + 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)); - // Get our results - try self.selectionSliceString(slices, &strbuilder, &mapbuilder); + // 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); - // 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 }; + // 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); } -/// 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; +test "Screen style reset to default" { + const testing = std.testing; + const alloc = testing.allocator; - if (skip > 0) { - skip -= 1; - continue; - } + 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)); - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; + // 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)); - 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, - }); - } - } + // 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)); +} - 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, - }); - } - } - } +test "Screen style reset with unset" { + const testing = std.testing; + const alloc = testing.allocator; - // 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, - }); - } - } - } - } + 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)); - // 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; - } + // 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)); - if (std.debug.runtime_safety) { - if (mapbuilder) |b| { - assert(strbuilder.items.len == b.items.len); - } - } + // 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)); } -const SelectionSlices = struct { - rows: usize, +test "Screen clearRows active one line" { + const testing = std.testing; + const alloc = testing.allocator; - // The selection that the slices below represent. This may not - // be the same as the input selection since some normalization - // occurs. - sel: Selection, + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); - // 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, + 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); +} - // Our bottom offset is used in rectangle select to always determine the - // maximum cell in a given row. - bot_offset: usize, +test "Screen clearRows active multi line" { + const testing = std.testing; + const alloc = testing.allocator; - // Our selection storage cell chunks. - top: []StorageCell, - bot: []StorageCell, -}; + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); -/// 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], - }; + 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); +} - const sel = sel: { - var sel = sel_raw; +test "Screen clearRows active styled line" { + const testing = std.testing; + const alloc = testing.allocator; - // Clamp the selection to the screen - if (sel.end.y >= self.rowsWritten()) { - sel.end.y = self.rowsWritten() - 1; - sel.end.x = self.cols - 1; - } + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); - // 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; - } - } + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("hello world"); + try s.setAttribute(.{ .unset = {} }); - // 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; - } - } + // We should have one style + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - break :sel sel; - }; + s.clearRows(.{ .active = .{} }, null, false); - // 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), - ); + // We should have none because active cleared it + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - // 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], - }; + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); } -/// 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; +test "Screen eraseRows history" { + const testing = std.testing; + const alloc = testing.allocator; - // 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; - }; + var s = try Screen.init(alloc, 5, 5, 1000); + defer s.deinit(); - // 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; - } + try s.testWriteString("1\n2\n3\n4\n5\n6"); - y -= 1; - try self.scroll(.{ .screen = 1 }); - } + { + 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); + } - // Get this row - const new_row = self.getRow(.{ .active = y }); - try new_row.copyRow(old_row); + s.eraseRows(.{ .history = .{} }, null); - // Next row - y += 1; + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } - - // 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); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } } -// X -test "Screen: clone empty viewport" { +test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try Screen.init(alloc, 5, 5, 1000); defer s.deinit(); + try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); + { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); - defer s2.deinit(); + 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); + } - // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); + 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: clone one line viewport" { +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(); - try s.testWriteString("1ABC"); + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + // Scroll down, should still be bottom + try s.cursorDownScroll(); { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); - defer s2.deinit(); - - // Test our contents - const contents = try s2.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", 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); } -} - -// 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(); + // Scrolling to the bottom does nothing + s.scroll(.{ .active = {} }); { - var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = 0 }); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.testString(alloc, .active); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -// X -test "Screen: clone one line active with extra space" { +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("1ABC"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // Should have 1 line written - try testing.expectEqual(@as(usize, 1), s.rowsWritten()); + // Scrolling up does nothing, but allows it + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport == .active); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", 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: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, 10, 3, 1); 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); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.cursorDownScroll(); - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } - // Going backward + // Scrolling to the bottom + s.scroll(.{ .active = {} }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } - // Going forward and backward + // Scrolling back should make it visible again + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport != .active); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Outside active area + // Scrolling back again should do nothing + s.scroll(.{ .delta_row = -1 }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } -} - -// X -test "Screen: selectAll" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 0); - defer s.deinit(); + // Scrolling to the bottom + s.scroll(.{ .active = {} }); { - 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); + 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 }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } -} - -// 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 + // Scrolling to the top should work + s.scroll(.{ .top = {} }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } -} - -// 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 > "); + // Should be able to easily clear active area only + s.clearRows(.{ .active = .{} }, null, false); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("ABCDE\nA \n> ", contents); + try testing.expectEqualStrings("1ABCD", 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); - } + // Scrolling to the bottom + s.scroll(.{ .active = {} }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } } -// X -test "Screen: selectLine across soft-wrap ignores blank lines" { +test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 3); 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); - } + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - // Going backward + // Scroll to top + s.scroll(.{ .top = {} }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Going forward and backward + // Scroll down multiple + s.scroll(.{ .delta_row = 5 }); + try testing.expect(s.pages.viewport == .active); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } } -// X -test "Screen: selectLine with scrollback" { +test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 5); + var s = try init(alloc, 10, 3, 50); 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 + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = 1 }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -// X -test "Screen: selectWord" { +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, 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.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); - } + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); - // Whitespace + // First test: we scroll up by 1, so we're not at the bottom anymore. + s.scroll(.{ .delta_row = -1 }); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } - // Whitespace single char + // Next, we scroll back down by 1, this grows the scrollback but we + // shouldn't move. + try s.cursorDownScroll(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } - // End of screen + // 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 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } } -// X -test "Screen: selectWord across soft-wrap" { +test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.testWriteString(" 1234012\n 123"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Going backward + try s.scrollClear(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -// X -test "Screen: selectWord whitespace across soft-wrap" { +test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.testWriteString("1 1\n 123"); + try s.testWriteString("1ABCD\n2EFGH"); - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } - // Going backward + try s.scrollClear(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } } -// X -test "Screen: selectWord with character boundary" { +test "Screen: scroll and clear empty screen" { 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); - } + 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); } } -// X -test "Screen: selectOutput" { +test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); - - // zig fmt: off + try s.testWriteString("1ABCD\n2EFGH"); + try s.scrollClear(); { - // 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 + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - // 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); + // Move back to top-left + s.cursorAbsolute(0, 0); - // 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 + // Write and clear + try s.testWriteString("3ABCD\n"); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("3ABCD", contents); } - // No end marker, should select till the end + + try s.scrollClear(); { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); } - // input / prompt at y = 0, pt.y = 0 + + // Move back to top-left + s.cursorAbsolute(0, 0); + try s.testWriteString("X"); + { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); } } -// X -test "Screen: selectPrompt basics" { +test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); - - // zig fmt: off + try s.testWriteString("1ABCD\n2EFGH"); { - // 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 + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } - // 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); + // 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); + } - // Not at a prompt + // Write to s1, should not be in s2 + try s.testWriteString("\n34567"); { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); - try testing.expect(sel == null); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents); } { - const sel = s.selectPrompt(.{ .x = 0, .y = 8 }); - try testing.expect(sel == null); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } +} - // Single line prompt +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 sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 6 }, - .end = .{ .x = 9, .y = 6 }, - }, sel); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } - // Multi line prompt + // Clone + var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); + defer s2.deinit(); { - const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 9, .y = 3 }, - }, sel); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); } } -// X -test "Screen: selectPrompt prompt at start" { +test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - // 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 s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); - 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); + // Test our contents rotated + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); + } - // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); - try testing.expect(sel == null); + 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(); - // 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 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); } } -// X -test "Screen: selectPrompt prompt at end" { +test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); + try s.testWriteString("1ABC"); - // 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 + 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); } - // zig fmt: on +} - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); +test "Screen: clone empty active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); - // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); - try testing.expect(sel == null); + 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"); - // 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 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); } } -// X -test "Screen: promptPath" { +test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); - - // zig fmt: off + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.pages.viewport == .active); + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); { - // 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 + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } - // zig fmt: on + { + // 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 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); + 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); - // From is not in the prompt + // Scroll to top + s.scroll(.{ .top = {} }); { - 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); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Same line + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); { - 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); + // 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); + } +} - // Different lines +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 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); + 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); } - // To is out of bounds before + 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 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); } - - // 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); + 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); } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp single" { +test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); + // Resize + try s.resizeWithoutReflow(10, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); + try testing.expectEqualStrings(str, contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp same line" { +test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + 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); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 1 }, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4ABCD", contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp single with pen" { +test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + 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); - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); 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); + try testing.expectEqualStrings("1ABCD", contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp multiple" { +test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + 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); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 1); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n4ABCD", contents); + try testing.expectEqualStrings("1ABCD", contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp multiple count" { +test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(20, 3); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 2); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n4ABCD", contents); + try testing.expectEqualStrings(str, contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp count greater than available lines" { +test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(4, 3); - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 10); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n\n\n4ABCD", contents); + const expected = "1ABC\n2EFG\n3IJK"; + try testing.expectEqualStrings(expected, contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp fills with pen" { + +test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 4, 5, 0); + var s = try init(alloc, 7, 3, 2); defer s.deinit(); - try s.testWriteString("A\nB\nC\nD"); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 10); - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); 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); + try testing.expectEqualStrings(str, contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap" { +test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 7, 3, 2); 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); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 2); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); 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); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap alternate" { +// 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, 0); + var s = try init(alloc, 5, 3, 5); 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"); + 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"); - // 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); + 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); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); 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); + try testing.expectEqualStrings("A\nB", contents); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap alternative with extra lines" { +test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, 2, 3, 3); defer s.deinit(); + const str = "1A2B\n3C4E\n5F6G"; + try s.testWriteString(str); - // 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); + // 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); { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n\n\n5EFGH", 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); } } -// X -test "Screen: clear history with no history" { +test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); + 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); + { - // 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); + try testing.expectEqualStrings(str, contents); } { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + try testing.expectEqualStrings(str, contents); } } -// X -test "Screen: clear history" { +test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(5, 10); - // 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); - } + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); { - // 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); + try testing.expectEqualStrings(str, contents); } { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + try testing.expectEqualStrings(str, contents); } } -// X -test "Screen: clear above cursor" { +test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 3); + var s = try init(alloc, 5, 3, 5); 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 str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } + + // Set our cursor to be on the "4" + s.cursorAbsolute(0, 1); { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + 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); } - 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; + // Resize + try s.resize(5, 10); - 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()); + // Cursor should still be on the "4" { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + 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.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n6IJKL", contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); } -// X -test "Screen: selectionString basic" { +test "Screen: resize more cols no reflow" { 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 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.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } } -// X -test "Screen: selectionString start outside of written area" { +// 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, 10, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); + try s.resize(10, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 5 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); } } -// X -test "Screen: selectionString end outside of written area" { +// 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, 10, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + 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.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, 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); } -// X -test "Screen: selectionString trim space" { +// 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, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1AB \n2EFGH\n3IJKL"; + 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.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1AB\n2EF"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings("2\n3\n4", contents); } - // No trim + try s.resize(4, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, false); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1AB \n2EF"; - try testing.expectEqualStrings(expected, 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); + // } } -// X -test "Screen: selectionString trim empty line" { +test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1AB \n\n2EFGH\n3IJKL"; + const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); + // Set one of the rows to be a prompt { - 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); + s.cursorAbsolute(0, 1); + s.cursor.page_row.semantic_prompt = .prompt; } - // No trim + try s.resize(10, 3); + { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 2 }, - }, false); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1AB \n \n2EF"; - try testing.expectEqualStrings(expected, 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); } } -// X -test "Screen: selectionString soft wrap" { +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); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; + const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); + // Verify we soft wrapped { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "2EFGH3IJ"; + const expected = "1ABCD\n2EFGH\n3IJKL"; 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"); + // 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.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - try testing.expectEqualStrings(expected, 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: selectionString wide char" { +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, 5, 0); + var s = try init(alloc, 6, 3, 0); defer s.deinit(); - const str = "1A⚡"; + const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); + // Verify we soft wrapped { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = str; + const expected = "1ABCD2\nEFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); } + // Let's put our cursor on the last row + s.cursorAbsolute(0, 2); { - 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 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.selectionString(alloc, .{ - .start = .{ .x = 3, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "⚡"; - try testing.expectEqualStrings(expected, 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); } } -// X -test "Screen: selectionString wide char with header" { +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); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABC⚡"; + 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.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 4, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = str; + const expected = "1ABCD\n2EFGH\n3IJKL"; 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(" "); + // Resize and verify we undid the soft wrap because we have space now + try s.resize(7, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "👨"; + 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); } -// X -test "Screen: selectionString with zero width joiner" { +test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 10, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "👨‍"; // this has a ZWJ + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - // Integrity check - const row = s.getRow(.{ .screen = 0 }); + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 2); { - 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 = 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 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 contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL"; + try testing.expectEqualStrings(expected, contents); } - // The real test + // Resize and verify we undid the soft wrap because we have space now + try s.resize(15, 3); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 1, .y = 0 }, - }, true); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "👨‍"; + const expected = "1ABCD2EFGH3IJKL"; 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); + // 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); } -// X -test "Screen: selectionString, rectangle, w/EOL" { +test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 30, 0); + var s = try init(alloc, 5, 3, 5); 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 - ; + 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); + } - 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; + // // 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); + } - 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); + // 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); + } - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - 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: dirty with getCellPtr" { +test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 3, 5); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABC\n2DEF\n3ABC\n4DEF"; + try s.testWriteString(str); - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); + // 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); } - // Reset our cursor onto the second row. - s.cursor.x = 0; - s.cursor.y = 1; - - try s.testWriteString("foo"); + // Verify we soft wrapped { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); + 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 row = s.getRow(.{ .active = 1 }); - try testing.expect(row.isDirty()); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1ABC\n2DEF\n3ABC\n4DEF"; + try testing.expectEqualStrings(expected, contents); } - { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(!row.isDirty()); - _ = row.getCell(0); - try testing.expect(!row.isDirty()); - } + // 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: dirty with clear, fill, fillSlice, copyRow" { +test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 4, 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 str = "1A2B\n3C4D"; + try s.testWriteString(str); { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.clear(.{}); - try testing.expect(row.isDirty()); - row.setDirty(false); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1A\n2B\n3C\n4D"; + try testing.expectEqualStrings(expected, contents); } - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.fill(.{ .char = 'A' }); - try testing.expect(row.isDirty()); - row.setDirty(false); - } + 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 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 contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } - { - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } } -test "Screen: dirty with graphemes" { +test "Screen: resize less rows no scrollback" { 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(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } + 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 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); + 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); } } -// X -test "Screen: resize (no reflow) more rows" { +test "Screen: resize less rows moving cursor" { 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); - // Clear dirty rows - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); + // 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.resizeWithoutReflow(10, 5); + try s.resize(5, 1); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } - // Everything should be dirty - iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); + // 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); } -// X -test "Screen: resize (no reflow) less rows" { +test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(2, 5); + try s.resize(5, 1); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", 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); } } -// X -test "Screen: resize (no reflow) less rows trims blank lines" { +test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD"; + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; 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 } }; - } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // 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); + // Resize + try s.resize(5, 1); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", 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); } } -// X -test "Screen: resize (no reflow) more rows trims blank lines" { +test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 3); defer s.deinit(); - const str = "1ABCD"; + const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; 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 } }; - } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // 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 testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - try s.resizeWithoutReflow(7, 5); + // Resize + try s.resize(5, 2); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", 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); } } -// X -test "Screen: resize (no reflow) more cols" { +test "Screen: resize less cols no reflow" { 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"; + const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str); - try s.resizeWithoutReflow(3, 10); + 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } } -// X -test "Screen: resize (no reflow) less cols" { +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); + var s = try init(alloc, 5, 3, 1); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD"; try s.testWriteString(str); - try s.resizeWithoutReflow(3, 4); + // 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1ABC\n2EFG\n3IJK"; + 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); } -// X -test "Screen: resize (no reflow) more rows with scrollback cursor end" { +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, 2); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(10, 5); + try s.resize(3, 3); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } } -// X -test "Screen: resize (no reflow) less rows with scrollback" { +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, 2); + var s = try init(alloc, 5, 3, 1); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(2, 5); + try s.resize(3, 3); { - const contents = try s.testString(alloc, .screen); + 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 = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; + const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); } } -// X -// https://github.com/mitchellh/ghostty/issues/1030 -test "Screen: resize (no reflow) less rows with empty trailing" { +test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; + const str = "3IJKL4ABCD5EFGH"; 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); + // Check { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("A\nB", contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, 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); + try s.resize(3, 3); - // 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()); + // { + // 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); + } } -// X -test "Screen: resize (no reflow) grapheme copy" { +test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); - // Attach graphemes to all the columns + // Put our cursor on the end + s.cursorAbsolute(1, s.pages.rows - 1); { - 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 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); } - // Clear dirty rows - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); - } + try s.resize(3, 3); - // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); + const expected = "3C\n4D\n5E"; try testing.expectEqualStrings(expected, contents); } - // Everything should be dirty - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); - } + // 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); } -// X -test "Screen: resize (no reflow) more rows with soft wrapping" { +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, 2, 3); + var s = try init(alloc, 5, 3, 2); defer s.deinit(); - const str = "1A2B\n3C4E\n5F6G"; + const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str); - // Every second row should be wrapped + // Check { - 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); - } + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); } - // Resize - try s.resizeWithoutReflow(10, 2); + // 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1A\n2B\n3C\n4E\n5F\n6G"; + 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); } - // Every second row should be wrapped + // 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); { - 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); - } + 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); } } -// X -test "Screen: resize more rows no scrollback" { +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, 0); + var s = try init(alloc, 5, 3, 5); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(10, 5); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // 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.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } -// X -test "Screen: resize more rows with empty scrollback" { +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, 10); + var s = try init(alloc, 5, 3, 3); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); } -} -// X -test "Screen: resize more rows with populated scrollback" { - const testing = std.testing; - const alloc = testing.allocator; + try s.resize(2, 10); - 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); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; + const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; + const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; try testing.expectEqualStrings(expected, contents); } } -// X -test "Screen: resize more rows and cols with wrapping" { +// 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, 4, 2, 0); + var s = try init(alloc, 5, 3, 10); defer s.deinit(); - const str = "1A2B\n3C4D"; + const str = "1ABC"; try s.testWriteString(str); + + // Grow + try s.resize(5, 10); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - const expected = "1A\n2B\n3C\n4D"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, 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); + // 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } } -// X -test "Screen: resize more cols no reflow" { +test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 1, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "😀"; 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); 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); + 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); } -} - -// 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); + // 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", 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); } } -// X -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) more cols with scrollback scrolled up" { +test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 3, 3, 0); defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; + const str = "x😀"; 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", 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(3, 8); + try s.resize(2, 3); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } - - // 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" { +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, 3, 5, 5); + var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; + const str = "😀"; 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); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); + try testing.expectEqualStrings(str, contents); } - - try s.resize(3, 4); { - const contents = try s.testString(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 contents = try s.testString(alloc, .active); - defer alloc.free(contents); - try testing.expectEqualStrings("6\n7\n8", contents); + 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); } - // Cursor remains at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); + try s.resize(1, 2); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } } -// X -test "Screen: resize more cols no reflow preserves semantic prompt" { +test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 3, 2, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = " 😀"; try s.testWriteString(str); - - // Set one of the rows to be a prompt { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(" \n😀", contents); } - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - + // 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 contents = try s.testString(alloc, .viewport); - 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.spacer_head, cell.wide); } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } - // Our one row should still be a semantic prompt, the others should not. + try s.resize(4, 2); { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); } { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); + 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 row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + 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); } } -// X -test "Screen: resize more cols grapheme map" { +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, 5, 0); + var s = try init(alloc, 3, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; + const str = "xxxyy😀"; 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 contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("xxx\nyy\n😀", contents); } - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - + // Similar to the "wide spacer head" test, but this time we'er going + // to increase our columns such that multiple rows are unwrapped. { - 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 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 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); + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); } -} - -// 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); + 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); } - // 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); + try s.resize(8, 2); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); 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); + { + 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); + } } -// X -test "Screen: resize more cols with reflow that ends in newline" { +test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 6, 0); + var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; + const str = "xx😀"; try s.testWriteString(str); - - // Verify we soft wrapped { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "1ABCD2\nEFGH\n3IJKL"; - try testing.expectEqualStrings(expected, 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); } - // 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); + // 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.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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); } - - // 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" { +test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 10, 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); + 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()).?); } - // 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); + 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()).?); } - - // 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" { +test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); + try s.testWriteString("ABC DEF\n 123\n456"); - // 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); + // Outside of active area + // try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); + // try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); - // Verify we soft wrapped + // Going forward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 15); + // Going backward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABCD2EFGH3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } - // 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); + // Going forward and backward { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } - // // 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); - + // Outside active area { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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: resize more cols with reflow" { +test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 5); + var s = try init(alloc, 5, 10, 0); 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); + try s.testWriteString(" 12 34012 \n 123"); + // Going forward { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABC\n2DEF\n3ABC\n4DEF"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } - - // 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" { +test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 10, 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); + try s.testWriteString(" 12 34012 \n 123"); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // 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 { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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: resize less rows moving cursor" { +test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 2, 3, 5); 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); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + // Selecting first line { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } } -// X -test "Screen: resize less rows with empty scrollback" { +// 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, 3, 5, 10); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resize(1, 5); + try s.testWriteString("ABCDE\nA > "); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, 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 { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); + 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: resize less rows with populated scrollback" { +test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); + 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } - // Resize - try s.resize(1, 5); + // 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 { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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()).?); } } -// X -test "Screen: resize less rows with full scrollback" { +test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); + try s.testWriteString(" 1234012\n 123"); + { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + try testing.expectEqualStrings(" 1234\n012\n 123", 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); + // 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 { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + 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: resize less cols no reflow" { +test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 10, 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); + try s.testWriteString("1 1\n 123"); - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // 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 { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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: resize less cols trailing background colors" { +test "Screen: selectWord with character boundary" { 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; + 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", + }; - // 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); - } + for (cases) |case| { + var s = try init(alloc, 20, 10, 0); + defer s.deinit(); + try s.testWriteString(case); - try s.resize(3, 5); + // 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()).?); + } - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); + // 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()).?); + } - { - 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); - } + // 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()).?); + } - // 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.*); + // 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()).?); + } } } -// X -test "Screen: resize less cols with graphemes" { +test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - // Attach graphemes to all the columns + // zig fmt: off { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < 3) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } + // 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 - 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 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 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); + 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 { - 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); + 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); } } -// X -test "Screen: resize less cols no reflow preserves semantic prompt" { +test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - // Set one of the rows to be a prompt + // zig fmt: off { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); + // 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 } - - 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); + // zig fmt: on { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - - // 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 pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } -} - -// 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); + // Not at a prompt { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); + try testing.expect(sel == null); } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 8, + } }).?); + try testing.expect(sel == null); } - // 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); - + // Single line prompt { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + 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: resize less cols with reflow with trimmed rows and scrollback" { +test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 1); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); + // zig fmt: off { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + // 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "4AB\nCD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } -} - -// 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); + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - - 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); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } -} - -// 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); + // Not at a prompt { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3C\n4D\n5E"; - try testing.expectEqualStrings(expected, contents); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 3, + } }).?); + try testing.expect(sel == null); } - // 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); + // 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()).?); + } } -// X -test "Screen: resize less cols with reflow previously wrapped and scrollback" { +test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 2); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; - try s.testWriteString(str); - // Check + // zig fmt: off { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); + // 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 } - - // 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); + // zig fmt: on { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "JKL\n4AB\nCD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } - // 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); - + // Not at a prompt { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); + try testing.expect(sel == null); } - // 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); + // 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()).?); + } } -// X -test "Screen: resize more rows, less cols with reflow with scrollback" { +test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; - try s.testWriteString(str); + // zig fmt: off { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); + // 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; } - - 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 pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } { - 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); + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } -} - -// 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 pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; } { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; } - // Shrink - try s.resize(3, 5); + // From is not in the prompt { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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 contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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); } - // Grow again - try s.resize(10, 5); + // Different lines { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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 contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + 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); } } -// X -test "Screen: resize less cols to eliminate wide char" { +test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 2, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "😀"; + const str = "1ABCD\n2EFGH\n3IJKL"; 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); + 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); - try testing.expectEqualStrings(" ", contents); + const expected = "2EFGH\n3IJ"; + try testing.expectEqualStrings(expected, 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" { +test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "x😀"; + const str = "1ABCD\n2EFGH\n3IJKL"; 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); + 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); - 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); + const expected = ""; + try testing.expectEqualStrings(expected, contents); } } -// X -test "Screen: resize less cols to eliminate wide char with row space" { +test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); - const str = "😀"; + const str = "1ABCD\n2EFGH\n3IJKL"; 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); + 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); - 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); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); } } -// X -test "Screen: resize more cols with wide spacer head" { +test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = " 😀"; + const str = "1AB \n2EFGH\n3IJKL"; 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); - } + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); - try s.resize(2, 4); { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1AB\n2EF"; + try testing.expectEqualStrings(expected, contents); } + + // No trim { - 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); + const contents = try s.selectionString(alloc, sel, false); + defer alloc.free(contents); + const expected = "1AB \n2EF"; + try testing.expectEqualStrings(expected, contents); } } -// X -test "Screen: resize less cols preserves grapheme cluster" { +test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 5, 0); + var s = try init(alloc, 5, 5, 0); defer s.deinit(); - const str: []const u8 = &.{ 0x43, 0xE2, 0x83, 0x90 }; // C⃐ (C with combining left arrow) + const str = "1AB \n\n2EFGH\n3IJKL"; 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 sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1AB\n\n2EF"; + try testing.expectEqualStrings(expected, contents); } - // Resize to less columns. No wrapping, but we should still have - // the same grapheme cluster. - try s.resize(1, 4); + // No trim { - const contents = try s.testString(alloc, .screen); + const contents = try s.selectionString(alloc, sel, false); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + const expected = "1AB \n \n2EF"; + try testing.expectEqualStrings(expected, contents); } } -// X -test "Screen: resize more cols with wide spacer head multiple lines" { +test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "xxxyy😀"; + const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); + { - const contents = try s.testString(alloc, .screen); + 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); - try testing.expectEqualStrings("xxx\nyy\n😀", 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); - // 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); + 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); } - try s.resize(2, 8); { - const contents = try s.testString(alloc, .screen); + 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); - try testing.expectEqualStrings(str, contents); + const expected = str; + try testing.expectEqualStrings(expected, 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); + 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: resize more cols requiring a wide spacer head" { +test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "xx😀"; + const str = "1ABC⚡"; 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); + 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); - 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); + const expected = str; + try testing.expectEqualStrings(expected, contents); } } -test "Screen: jump zero" { +// 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, 3, 5, 10); + var s = try init(alloc, 5, 2, 0); 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); - } + // 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 row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); + 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); } - - try testing.expect(!s.jump(.{ .prompt_delta = 0 })); - try testing.expectEqual(@as(usize, 3), s.viewport); } -test "Screen: jump to prompt" { +test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 10, 1, 0); defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); + const str = "👨‍"; // this has a ZWJ + try s.testWriteString(str); - // Set semantic prompts + // Integrity check { - const row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.prompt); + 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 row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); + 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); } +} - // 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); +test "Screen: selectionString, rectangle, basic" { + const testing = std.testing; + const alloc = testing.allocator; - // Jump forward - try testing.expect(s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); + 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); - // Jump forward - try testing.expect(!s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); + const contents = try s.selectionString(alloc, sel, true); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); } -test "Screen: row graphemeBreak" { +test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 10, 0); + var s = try init(alloc, 30, 5, 0); defer s.deinit(); - try s.testWriteString("x"); - try s.testWriteString("👨‍A"); + 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 row = s.getRow(.{ .screen = 0 }); + 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; - // Normal char is a break - try testing.expect(row.graphemeBreak(0)); + 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); - // Emoji with ZWJ is not - try testing.expect(!row.graphemeBreak(1)); + const contents = try s.selectionString(alloc, sel, true); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); } diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index d29513d73e..a404cf0e52 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -1,188 +1,181 @@ -/// 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); - - // 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. +/// 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. /// -/// 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; +/// 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, + }, +}; - // If between the top/bottom, always good. - return p.y > tl.y and p.y < br.y; +/// 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 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; +pub fn deinit( + self: Selection, + s: *Screen, +) void { + switch (self.bounds) { + .tracked => |v| { + s.pages.untrackPin(v.start); + s.pages.untrackPin(v.end); + }, - return true; + .untracked => {}, + } } -/// 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; +/// 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; } -/// 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, +/// 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, }; +} - 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 }, - }; - } +/// 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, + }; +} - // 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, - }; - } +pub fn start(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.start, + .tracked => |v| v.start.*, + }; +} - // 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 }, +pub fn end(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.end, + .tracked => |v| v.end.*, }; } -/// 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 true if this is a tracked selection. +pub fn tracked(self: *const Selection) bool { + return switch (self.bounds) { + .untracked => false, + .tracked => true, }; } -/// 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 }, +/// 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 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 }, +/// 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; + }, }; } @@ -200,28 +193,88 @@ pub fn ordered(self: Selection, desired: Order) Selection { /// pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; -pub fn order(self: Selection) Order { +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 (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; + 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 (self.start.y > self.end.y and self.start.x < self.end.x) return .mirrored_reverse; + if (start_pt.y > end_pt.y and start_pt.x < end_pt.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; + if (start_pt.y < end_pt.y and start_pt.x > end_pt.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; + 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, @@ -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); - - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }, sel); + 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. { - 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); + 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 { - 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); + 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()).?); } } -// 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); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }, sel); + 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 { - 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); + 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()).?); } } -// 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); - - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 4 }, - }, sel); + 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 { - 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); + 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()).?); } } -// 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); - } -} - -// 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 })); + 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, rectangle" { +test "Selection: adjust home" { 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 })); - } + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); - // Reverse + // On the last line { - 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 })); - } + 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); - // 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 })); + // 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: containedRow" { +test "Selection: adjust end with not full screen" { 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 s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); - // 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 + // 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 }, - }; - - try testing.expect(sel.order() == .forward); + 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 = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 2, .y = 1 }, - }; - - try testing.expect(sel.order() == .reverse); + 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 = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - - try testing.expect(sel.order() == .forward); + 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 = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 1 }, - }; - - try testing.expect(sel.order() == .forward); + 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 = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - - try testing.expect(sel.order() == .reverse); + 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); } } -// 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, - }; - - try testing.expect(sel.order() == .forward); + 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 = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .reverse); + 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 = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .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); + + 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, - }; - - try testing.expect(sel.order() == .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); + + 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, - }; - - try testing.expect(sel.order() == .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); + + 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, - }; - - try testing.expect(sel.order() == .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); + + 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, - }; - - try testing.expect(sel.order() == .forward); + 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 = .{ - .start = .{ .x = 2, .y = 3 }, - .end = .{ .x = 2, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .reverse); + 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 = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.order() == .forward); + 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); } } -// 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)); + 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(); { - // 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)); + 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 { - // 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)); + 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 { - // 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 } }).?, + 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 5ff2591cbe..94d33f7343 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,500 +188,422 @@ 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, -}; - -/// 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 }); +/// 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(); + }, - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; + else => try self.print(cp), + } + } +} - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); +/// 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); + } +} - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .alternate; +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - // Bring our pen with us - self.screen.cursor = old.cursor; + // If we're not on the main display, do nothing for now + if (self.status_display != .main) return; - // Bring our charset state with us - self.screen.charset = old.charset; + // 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; - // Clear our selection - self.screen.selection = null; + // 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; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // 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 (options.clear_on_enter) { - self.eraseDisplay(alloc, .complete, false); - } -} + // 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, + }, + }; + }; -/// 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 }); + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (!prev.cell.hasText()) break :grapheme; - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; + 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; + } + } - if (options.clear_on_exit) self.eraseDisplay(alloc, .complete, false); + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; + // 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; - // Clear our selection - self.screen.selection = null; + switch (c) { + 0xFE0F => wide: { + if (prev.cell.wide == .wide) break :wide; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screen.cursorLeft(prev.left); - // Restore the cursor from the primary screen - if (options.cursor_save) self.restoreCursor(); -} + // 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(); + } -/// The modes for DECCOLM. -pub const DeccolmMode = enum(u1) { - @"80_cols" = 0, - @"132_cols" = 1, -}; + self.printCell(prev.cell.content.codepoint, .wide); -/// DECCOLM changes the terminal width between 80 and 132 columns. This -/// function call will do NOTHING unless `setDeccolmSupported` has been -/// called with "true". -/// -/// This breaks the expectation around modern terminals that they resize -/// with the window. This will fix the grid at either 80 or 132 columns. -/// The rows will continue to be variable. -pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { - // If DEC mode 40 isn't enabled, then this is ignored. We also make - // sure that we don't have deccolm set because we want to fully ignore - // set mode. - if (!self.modes.get(.enable_mode_3)) { - self.modes.set(.@"132_column", false); - return; - } + // Write our spacer + self.screen.cursorRight(1); + self.printCell(' ', .spacer_tail); - // Enable it - self.modes.set(.@"132_column", mode == .@"132_cols"); + // 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); + } + }, - // Resize to the requested size - try self.resize( - alloc, - switch (mode) { - .@"132_cols" => 132, - .@"80_cols" => 80, - }, - self.rows, - ); + 0xFE0E => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; - // Erase our display and move our cursor. - self.eraseDisplay(alloc, .complete, false); - self.setCursorPos(1, 1); -} + // Remove the wide spacer tail + const cell = self.screen.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; -/// Resize the underlying terminal. -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; + break :narrow; + }, - // 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); - } + else => unreachable, + } + } - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - self.clearPromptForResize(); - if (self.modes.get(.wraparound)) { - try self.screen.resize(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - } - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(rows, cols); - } else { - try self.secondary_screen.resizeWithoutReflow(rows, cols); + 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; } } - // Set our size - self.cols = cols; - self.rows = rows; + // 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); - // Reset the scrolling region - self.scrolling_region = .{ - .top = 0, - .bottom = rows - 1, - .left = 0, - .right = cols - 1, - }; -} + // 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 }); -/// If shell_redraws_prompt is true and we're on the primary screen, -/// 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, + // 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 don't know, we keep searching. - .unknown => {}, - } + // 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; } - 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; - }, + // 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); + }; - .inverse => { - self.screen.cursor.pen.attrs.inverse = true; - }, + // 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; + } - .reset_inverse => { - self.screen.cursor.pen.attrs.inverse = false; - }, + // 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; + } - .invisible => { - self.screen.cursor.pen.attrs.invisible = true; - }, + try self.screen.cursor.page_pin.page.data.appendGrapheme( + self.screen.cursor.page_row, + prev, + c, + ); + return; + } - .reset_invisible => { - self.screen.cursor.pen.attrs.invisible = false; - }, + // We have a printable character, save it + self.previous_char = c; - .strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = true; - }, + // If we're soft-wrapping, then handle that first. + if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { + try self.printWrap(); + } - .reset_strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = false; - }, + // 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); + } - .direct_color_fg => |rgb| { - self.screen.cursor.pen.fg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, + switch (width) { + // Single cell is very easy: just write in the cell + 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), - .direct_color_bg => |rgb| { - self.screen.cursor.pen.bg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, + // 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; - .@"8_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, + self.printCell(' ', .spacer_head); + try self.printWrap(); + } - .@"8_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; + 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); }, - .reset_fg => self.screen.cursor.pen.fg = .none, + else => unreachable, + } - .reset_bg => self.screen.cursor.pen.bg = .none, + // 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; + } - .@"8_bright_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, + // Move the cursor + self.screen.cursorRight(1); +} - .@"8_bright_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; - }, +fn printCell( + self: *Terminal, + unmapped_c: u21, + wide: Cell.Wide, +) void { + // TODO: spacers should use a bgcolor only cell - .@"256_fg" => |idx| { - self.screen.cursor.pen.fg = .{ .indexed = idx }; - }, + const c: u21 = c: { + // TODO: non-utf8 handling, gr - .@"256_bg" => |idx| { - self.screen.cursor.pen.bg = .{ .indexed = idx }; - }, + // 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); - .unknown => return error.InvalidAttribute, - } -} + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) break :c unmapped_c; -/// 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(); + // 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 ' '; - // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS - try writer.writeByte('0'); + // Get our lookup table and map it + const table = set.table(); + break :c @intCast(table[@intCast(unmapped_c)]); + }; - const pen = self.screen.cursor.pen; - var attrs = [_]u8{0} ** 8; - var i: usize = 0; + 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; + } + }, - if (pen.attrs.bold) { - attrs[i] = '1'; - i += 1; - } + .spacer_tail => { + assert(self.screen.cursor.x > 0); - if (pen.attrs.faint) { - attrs[i] = '2'; - i += 1; - } + 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; + } + }, - if (pen.attrs.italic) { - attrs[i] = '3'; - i += 1; + // 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 (pen.attrs.underline != .none) { - attrs[i] = '4'; - i += 1; + // 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, + ); } - if (pen.attrs.blink) { - attrs[i] = '5'; - i += 1; - } + // Keep track of the previous style so we can decrement the ref count + const prev_style_id = cell.style_id; - if (pen.attrs.inverse) { - attrs[i] = '7'; - i += 1; - } + // Write + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.screen.cursor.style_id, + .wide = wide, + .protected = self.screen.cursor.protected, + }; - if (pen.attrs.invisible) { - attrs[i] = '8'; - i += 1; - } + // 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 (pen.attrs.strikethrough) { - attrs[i] = '9'; - i += 1; + // If we have a ref-counted style, increase. + if (self.screen.cursor.style_ref) |ref| { + ref.* += 1; + self.screen.cursor.page_row.styled = true; + } } +} - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); - } +fn printWrap(self: *Terminal) !void { + self.screen.cursor.page_row.wrap = true; - 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), - } + // 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; - 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), - } + // Move to the next line + try self.index(); + self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); - return stream.getWritten(); + // 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. @@ -712,537 +631,1094 @@ pub fn invokeCharset( } } -/// 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(); +/// 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; }, - else => try self.print(cp), + // 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); } } -pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); +/// 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; + } - // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; + self.scrollDown(1); +} - // 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; +// 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, + }; - // 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 }); + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; - // 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; + // 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 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 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); + } - // 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, - }; + return; + } - break :prev .{ - .cell = row.getCellPtr(x - 1), - .x = x - 1, - }; - }; + // 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 }); +} - // If our cell has no content, then this is a new cell and - // necessarily a grapheme break. - if (prev.cell.char == 0) break :grapheme; +/// 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; - 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; - } - } + self.scrolling_region.top = @intCast(top - 1); + self.scrolling_region.bottom = @intCast(bottom - 1); + self.setCursorPos(1, 1); +} - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); - }; +/// 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; - // 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; + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; - switch (c) { - 0xFE0F => wide: { - if (prev.cell.attrs.wide) break :wide; + self.scrolling_region.left = @intCast(left - 1); + self.scrolling_region.right = @intCast(right - 1); + self.setCursorPos(1, 1); +} - // 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; +/// 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; + } - // 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(); - } + // 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; + } - const wide_cell = self.printCell(@intCast(prev.cell.char)); - wide_cell.attrs.wide = true; + // Move to the top of the scroll region + self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.deleteLines(count); +} - // Write our spacer - self.screen.cursor.x += 1; - const spacer = self.printCell(' '); - spacer.attrs.wide_spacer_tail = true; +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(enum) { + /// Scroll to the top of the scrollback + top: void, - // 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; - } - }, + /// 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; - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (!prev.cell.attrs.wide) break :narrow; - prev.cell.attrs.wide = false; + // 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; - // Remove the wide spacer tail - const cell = row.getCellPtr(prev.x + 1); - cell.attrs.wide_spacer_tail = false; + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; - break :narrow; - }, + // The maximum we can delete is the remaining lines in the scroll region. + const count = @min(count_req, rem); - else => unreachable, - } + // 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; } - log.debug("c={x} grapheme attach to x={}", .{ c, prev.x }); - try row.attachGrapheme(prev.x, c); - return; + // 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, + ); } } - // 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); + const bottom: [*]Row = top + (rem - 1); + while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { + const row: *Row = @ptrCast(y); - // 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 }); + // 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); + } - // 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; + // 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, + ); - // 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; - } + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; +} - // 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; - }; +/// 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 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; - } + // 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; - try row.attachGrapheme(prev, c); - 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; + // } - // We have a printable character, save it - self.previous_char = c; + // 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 we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) - try self.printWrap(); + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screen.cursor.x + 1; - // 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); - } + // We can only insert blanks up to our remaining cols + const adjusted_count = @min(count, rem); - switch (width) { - // Single cell is very easy: just write in the cell - 1 => _ = @call(.always_inline, printCell, .{ self, c }), + // 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); - // 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; + // 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]); + } - const spacer_head = self.printCell(' '); - spacer_head.attrs.wide_spacer_head = true; - try self.printWrap(); - } + // 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); - const wide_cell = self.printCell(c); - wide_cell.attrs.wide = true; + // 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); + } - // 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(' '); - }, + // Copy our src to our dst + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; - else => unreachable, + // 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); + } + } } - // 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; - } + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); } -fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { - const c: u21 = c: { - // TODO: non-utf8 handling, gr +/// 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 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); + // 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; - // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; - // 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 ' '; + // 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; - // Get our lookup table and map it - const table = set.table(); - break :c @intCast(table[@intCast(unmapped_c)]); - }; + // 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]); + } - 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; - } + // 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 (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); + // 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]); } - } 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; + 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 (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); + // 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); + } } } - // 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; + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); } -fn printWrap(self: *Terminal) !void { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setWrapped(true); +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = @max(count_req, 1); - // 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(); + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; - // Move to the next line - try self.index(); - self.screen.cursor.x = self.scrolling_region.left; + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; - // 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); -} + // 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); -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; -} + // 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; + } -/// 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); + 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], + ); } } -/// 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, - }; +/// 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; - // Origin mode is disabled - self.modes.set(.origin, false); + // 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; + } - // Move our cursor to the top-left - self.setCursorPos(1, 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; + } - // 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, + break :left .{ 0, x + 1 }; }, - }; - // Our pen has the letter E - const pen: Screen.Cell = .{ .char = 'E' }; + // 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 }, - // Fill with Es, does not move cursor. - for (0..self.rows) |y| { - const filled = self.screen.getRow(.{ .active = y }); - filled.fill(pen); - } -} + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; -/// 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 + // 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; - // 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; - } + // Start of our cells + const cells: [*]Cell = cells: { + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + break :cells cells - self.screen.cursor.x; + }; - // 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); - } + // 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; } - // 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; + 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], + ); } - - 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. @@ -1250,9 +1726,9 @@ pub fn eraseDisplay( switch (mode) { .scroll_complete => { - self.screen.scroll(.{ .clear = {} }) catch |err| { + self.screen.scrollClear() catch |err| { log.warn("scroll clear failed, doing a normal clear err={}", .{err}); - self.eraseDisplay(alloc, .complete, protected_req); + self.eraseDisplay(.complete, protected_req); return; }; @@ -1260,7 +1736,8 @@ pub fn eraseDisplay( self.screen.cursor.pending_wrap = false; // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + // TODO + // self.screen.kitty_images.delete(alloc, self, .{ .all = true }); }, .complete => { @@ -1270,86 +1747,66 @@ pub fn eraseDisplay( // 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; - } - } + // 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 - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + // TODO + //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); - } + self.eraseLine(.right, protected_req); // 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; - } + if (self.screen.cursor.y + 1 < self.rows) { + self.screen.clearRows( + .{ .active = .{ .y = self.screen.cursor.y + 1 } }, + null, + protected, + ); } - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + // Unsets pending wrap state. Should be done by eraseLine. + assert(!self.screen.cursor.pending_wrap); }, .above => { @@ -1357,1987 +1814,1935 @@ pub fn eraseDisplay( 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; - } + if (self.screen.cursor.y > 0) { + self.screen.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + protected, + ); } // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + assert(!self.screen.cursor.pending_wrap); }, - .scrollback => self.screen.clear(.history) catch |err| { - // This isn't a huge issue, so just log it. - log.err("failed to clear scrollback: {}", .{err}); - }, + .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), } } -/// 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 }, +/// 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(); - // 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; - } + // Reset margins, also sets cursor to top-left + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; - // This resets the soft-wrap of this line - row.setWrapped(false); + // Origin mode is disabled + self.modes.set(.origin, false); - break :right .{ x, row.lenCells() }; - }, + // Move our cursor to the top-left + self.setCursorPos(1, 1); - .left => left: { - var x = self.screen.cursor.x; + // Erase the display which will deallocate graphames, styles, etc. + self.eraseDisplay(.complete, false); - // 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; - } + // 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; } + } + } +} - break :left .{ 0, x + 1 }; - }, +/// 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, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} - // 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() }, +/// Set a style attribute. +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + try self.screen.setAttribute(attr); +} - else => { - log.err("unimplemented erase line mode: {}", .{mode}); - return; - }, - }; +/// 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(); - // 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; + // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS + try writer.writeByte('0'); - // 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; + const pen = self.screen.cursor.style; + var attrs = [_]u8{0} ** 8; + var i: usize = 0; - // 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; + if (pen.flags.bold) { + attrs[i] = '1'; + i += 1; } - for (start..end) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; + if (pen.flags.faint) { + attrs[i] = '2'; + i += 1; } -} - -/// 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; + if (pen.flags.italic) { + attrs[i] = '3'; + i += 1; + } - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.bg, - }; + if (pen.flags.underline != .none) { + attrs[i] = '4'; + i += 1; + } - // 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; - } + if (pen.flags.blink) { + attrs[i] = '5'; + i += 1; + } + + if (pen.flags.inverse) { + attrs[i] = '7'; + i += 1; } - // 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; - } + if (pen.flags.invisible) { + attrs[i] = '8'; + i += 1; + } - 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; + if (pen.flags.strikethrough) { + attrs[i] = '9'; + i += 1; } -} -pub fn eraseChars(self: *Terminal, count_req: usize) void { - const count = @max(count_req, 1); + for (attrs[0..i]) |c| { + try writer.print(";{c}", .{c}); + } - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; + 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), + } - // 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); + 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), + } - // 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; - } + return stream.getWritten(); +} - break :end end; - }; +/// The modes for DECCOLM. +pub const DeccolmMode = enum(u1) { + @"80_cols" = 0, + @"132_cols" = 1, +}; - // This resets the soft-wrap of this line - row.setWrapped(false); +/// DECCOLM changes the terminal width between 80 and 132 columns. This +/// function call will do NOTHING unless `setDeccolmSupported` has been +/// called with "true". +/// +/// This breaks the expectation around modern terminals that they resize +/// with the window. This will fix the grid at either 80 or 132 columns. +/// The rows will continue to be variable. +pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if (!self.modes.get(.enable_mode_3)) { + self.modes.set(.@"132_column", false); + return; + } - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.bg, - }; + // Enable it + self.modes.set(.@"132_column", mode == .@"132_cols"); - // 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); - } + // Resize to the requested size + try self.resize( + alloc, + switch (mode) { + .@"132_cols" => 132, + .@"80_cols" => 80, + }, + self.rows, + ); - // 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; - } + // Erase our display and move our cursor. + self.eraseDisplay(.complete, false); + self.setCursorPos(1, 1); } -/// 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); +/// Resize the underlying terminal. +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; - // 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; + // Resize our tabstops + if (self.cols != cols) { + self.tabstops.deinit(alloc); + self.tabstops = try Tabstops.init(alloc, cols, 8); } - // 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; + // If we're making the screen smaller, dealloc the unused items. + if (self.active_screen == .primary) { + self.clearPromptForResize(); + if (self.modes.get(.wraparound)) { + try self.screen.resize(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + } + try self.secondary_screen.resizeWithoutReflow(rows, cols); + } else { + try self.screen.resizeWithoutReflow(rows, cols); + if (self.modes.get(.wraparound)) { + try self.secondary_screen.resize(rows, cols); + } else { + try self.secondary_screen.resizeWithoutReflow(rows, cols); + } } - // 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; + // Set our size + self.cols = cols; + self.rows = rows; - // 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; - }, + // Reset the scrolling region + self.scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }; +} - // Handled in while loop - .reverse_extended => {}, +/// If shell_redraws_prompt is true and we're on the primary screen, +/// 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; +} - // Handled above - .none => unreachable, - } - } +/// Set the pwd for the terminal. +pub fn setPwd(self: *Terminal, pwd: []const u8) !void { + self.pwd.clearRetainingCapacity(); + try self.pwd.appendSlice(pwd); +} - while (true) { - // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[]const u8 { + if (self.pwd.items.len == 0) return null; + return self.pwd.items; +} - // 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; +/// 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, +}; - // If we have no more to move, then we're done. - if (count == 0) break; +/// 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, + options: AlternateScreenOptions, +) void { + //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); - // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { - if (wrap_mode != .reverse_extended) break; + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + // for now, we ignore... + if (self.active_screen == .alternate) return; - self.screen.cursor.y = bottom; - self.screen.cursor.x = right_margin; - count -= 1; - continue; - } + // If we requested cursor save, we save the cursor in the primary screen + if (options.cursor_save) self.saveCursor(); - // 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; - } + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .alternate; - // 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; - } + // Bring our charset state with us + self.screen.charset = old.charset; - self.screen.cursor.y -= 1; - self.screen.cursor.x = right_margin; - count -= 1; - } -} + // Clear our selection + self.screen.selection = null; -/// 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; + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; - // 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; + // 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); - const count = @max(count_req, 1); - self.screen.cursor.x = @min(max, self.screen.cursor.x +| count); + 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}); + }; } -/// 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; +/// 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 }); - // 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; + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + if (self.active_screen == .primary) return; - const count = @max(count_req, 1); - self.screen.cursor.y = @min(max, self.screen.cursor.y +| count); -} + if (options.clear_on_exit) self.eraseDisplay(.complete, false); -/// 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; + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; - // 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; + // Clear our selection + self.screen.selection = null; - const count = @max(count_req, 1); - self.screen.cursor.y = @max(min, self.screen.cursor.y -| count); -} + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; -/// Backspace moves the cursor back a column (but not less than 0). -pub fn backspace(self: *Terminal) void { - self.cursorLeft(1); + // 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}); + }; } -/// 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; - } +/// 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 = .{} }); } -// 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; +/// Full reset +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.saved_cursor = null; + self.screen.selection = null; + self.screen.kitty_keyboard = .{}; + self.screen.protected_mode = .off; + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + self.previous_char = null; + self.eraseDisplay(.scrollback, false); + self.eraseDisplay(.complete, false); + self.screen.cursorAbsolute(0, 0); + self.pwd.clearRetainingCapacity(); + self.status_display = .main; +} - while (true) { - // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; +test "Terminal: input with no control characters" { + const alloc = testing.allocator; + var t = try init(alloc, 40, 40); + defer t.deinit(alloc); - // Move the cursor left - self.screen.cursor.x -= 1; - if (self.tabstops.get(self.screen.cursor.x)) return; + // 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); + try testing.expectEqualStrings("hello", str); } } -/// 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}), +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); } } -/// Set a tab stop on the current cursor. -/// TODO: test -pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); -} +test "Terminal: input that forces scroll" { + const alloc = testing.allocator; + var t = try init(alloc, 1, 5); + defer t.deinit(alloc); -/// TODO: test -pub fn tabReset(self: *Terminal) void { - self.tabstops.reset(TABSTOP_INTERVAL); + // 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); + } } -/// 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; +test "Terminal: zero-width character at start" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - // 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; -} + // This used to crash the terminal. This is not allowed so we should + // just ignore it. + try t.print(0x200D); -/// Linefeed moves the cursor to the next line. -pub fn linefeed(self: *Terminal) !void { - try self.index(); - if (self.modes.get(.linefeed)) self.carriageReturn(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -/// 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; +// https://github.com/mitchellh/ghostty/issues/1400 +test "Terminal: print single very long line" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); - // 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 would crash for issue 1400. So the assertion here is + // that we simply do not crash. + for (0..1000) |_| try t.print('x'); +} - // 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; +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); - // 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; + 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); } +} - // Get the current row - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); +test "Terminal: print wide char with 1-column width" { + const alloc = testing.allocator; + var t = try init(alloc, 1, 2); + defer t.deinit(alloc); - // Determine our indexes. - const start = self.screen.cursor.x; - const pivot = @min(self.screen.cursor.x + count, right_limit); + try t.print('😀'); // 0x1F600 +} - // 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; +test "Terminal: print wide char in single-width terminal" { + var t = try init(testing.allocator, 1, 80); + defer t.deinit(testing.allocator); - // 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; - } - } + 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); - // Insert blanks. The blanks preserve the background color. - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, start, pivot); + { + 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); + } } -/// 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; +test "Terminal: print over wide char at 0,0" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - // 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; + try t.print(0x1F600); // Smiley face + t.setCursorPos(0, 0); + try t.print('A'); // Smiley face - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - // Remaining rows from our cursor - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 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(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); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} - // If count is greater than the amount of rows, adjust down. - const adjusted_count = @min(count, rem); +test "Terminal: print over wide spacer tail" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + try t.print('橋'); + t.setCursorPos(1, 2); + try t.print('X'); - // 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); - } + { + 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); } - // 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); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); } } -/// 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; +test "Terminal: print multicodepoint grapheme, disabled mode 2027" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; + // https://github.com/mitchellh/ghostty/issues/289 + // 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 6 cells taken up + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); - // 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) + // Assert various properties about our screen to verify + // we have all expected cells. { - 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; + 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); } - - // 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; + { + 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); } - - // 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); - } + { + 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); } - - // 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); + { + 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 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 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); } } -/// 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; +test "Terminal: VS16 doesn't make character with 2027 disabled" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); - // 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); -} + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); -/// 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; + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide - // 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); + { + 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); + } } -/// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { - /// Scroll to the top of the scrollback - top: void, +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); - /// Scroll to the bottom, i.e. the top of the active area - bottom: void, + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); - /// Scroll by some delta amount, up is negative. - delta: isize, -}; + // 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); -/// 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 }, - }); + // 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); + } } -/// 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); -} +test "Terminal: print multicodepoint grapheme, mode 2027" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); -/// 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; + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - const left = @max(1, left_req); - const right = @min(self.cols, if (right_req == 0) self.cols else right_req); - if (left >= right) return; + // https://github.com/mitchellh/ghostty/issues/289 + // 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); - self.scrolling_region.left = left - 1; - self.scrolling_region.right = right - 1; - self.setCursorPos(1, 1); -} + // 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, 2), t.screen.cursor.x); -/// 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, - }); + // 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, 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 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); + } } -/// 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; +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); - 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, + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow - // If we don't know, we keep searching. - .unknown => {}, - } + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("⛈︎", str); } - return false; + { + 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); + } } -/// Set the pwd for the terminal. -pub fn setPwd(self: *Terminal, pwd: []const u8) !void { - self.pwd.clearRetainingCapacity(); - try self.pwd.appendSlice(pwd); -} +test "Terminal: VS16 to make wide character with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); -/// Returns the pwd for the terminal, if any. The memory is owned by the -/// Terminal and is not copied. It is safe until a reset or setPwd. -pub fn getPwd(self: *const Terminal) ?[]const u8 { - if (self.pwd.items.len == 0) return null; - return self.pwd.items; -} + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, 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, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); -} + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide -/// 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; + { + 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); + } +} - // screen.protected_mode is NEVER reset to ".off" because - // logic such as eraseChars depends on knowing what the - // _most recent_ mode was. - }, +test "Terminal: VS16 repeated with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); - .iso => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .iso; - }, + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - .dec => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .dec; - }, + 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); } -} -/// Full reset -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 = .{}; - self.screen.protected_mode = .off; - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; - self.previous_char = null; - self.eraseDisplay(alloc, .scrollback, false); - self.eraseDisplay(alloc, .complete, false); - self.pwd.clearRetainingCapacity(); - self.status_display = .main; + { + 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: fullReset with a non-empty pen" { +test "Terminal: print invalid VS16 grapheme" { 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); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // 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); - 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); + // 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); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } } -// X -test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, 10, 10); +test "Terminal: print invalid VS16 with second char" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - t.setCursorPos(3, 5); - t.modes.set(.origin, true); - t.fullReset(testing.allocator); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // Origin mode should be reset and the cursor should be moved + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); + try t.print('y'); + + // 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, 0), t.screen.cursor.x); - try testing.expect(!t.modes.get(.origin)); + try testing.expectEqual(@as(usize, 2), 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, 'y'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } } -// X -test "Terminal: fullReset status display" { - var t = try init(testing.allocator, 10, 10); +test "Terminal: overwrite grapheme should clear grapheme data" { + var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - t.status_display = .status_line; - t.fullReset(testing.allocator); - try testing.expect(t.status_display == .main); + // 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 + t.setCursorPos(1, 1); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } } -// X -test "Terminal: input with no control characters" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print writes to bottom if scrolled" { + var t = try init(testing.allocator, 5, 2); 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); + t.setCursorPos(0, 0); + + // Make newlines so we create scrollback + // 3 pushes hello off the screen + try t.index(); + try t.index(); + try t.index(); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to the top + t.screen.scroll(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("hello", str); } + + // Type + try t.print('A'); + t.screen.scroll(.{ .active = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } } -// X -test "Terminal: zero-width character at start" { +test "Terminal: print charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - // This used to crash the terminal. This is not allowed so we should - // just ignore it. - try t.print(0x200D); + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // Basic grid writing + try t.print('`'); + t.configureCharset(.G0, .utf8); + try t.print('`'); + t.configureCharset(.G0, .ascii); + try t.print('`'); + t.configureCharset(.G0, .dec_special); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("```◆", str); + } } -// https://github.com/mitchellh/ghostty/issues/1400 -// X -test "Terminal: print single very long line" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: print charset outside of ASCII" { + var t = try init(testing.allocator, 80, 80); 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'); + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + + // Basic grid writing + t.configureCharset(.G0, .dec_special); + try t.print('`'); + try t.print(0x1F600); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆ ", str); + } } -// X -test "Terminal: print over wide char at 0,0" { +test "Terminal: print invoke charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - try t.print(0x1F600); // Smiley face - t.setCursorPos(0, 0); - try t.print('A'); // Smiley face - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + t.configureCharset(.G1, .dec_special); - 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)); - } + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, false); + try t.print('`'); + try t.print('`'); + t.invokeCharset(.GL, .G0, false); + try t.print('`'); { - const cell = row.getCell(1); - try testing.expect(!cell.attrs.wide_spacer_tail); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`◆◆`", str); } } -// X -test "Terminal: print over wide spacer tail" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: print invoke charset single" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - try t.print('橋'); - t.setCursorPos(1, 2); - try t.print('X'); + t.configureCharset(.G1, .dec_special); + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, true); + try t.print('`'); + try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("`◆`", 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)); - } +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 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)); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hel\nlo", str); } } -// X -test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, 3, 80); 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 + t.markSemanticPrompt(.prompt); + for ("hello") |c| try t.print(c); { - 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 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } - - 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)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } } -// X -test "Terminal: VS16 to make wide character with mode 2027" { +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(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + // 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("❤️", str); + try testing.expectEqualStrings("AAAA", str); } - const row = t.screen.getRow(.{ .screen = 0 }); + // Make sure we printed nothing { - 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 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); } } -// X -test "Terminal: VS16 repeated with mode 2027" { +test "Terminal: disabled wraparound with wide char and no 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(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide + // 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("❤️❤️", str); + try testing.expectEqualStrings("AAAAA", 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)); + 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); } } -// X -test "Terminal: VS16 doesn't make character with 2027 disabled" { +test "Terminal: disabled wraparound with wide grapheme and half space" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, false); + 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("❤️", str); + try testing.expectEqualStrings("AAAA❤", 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 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 multicodepoint grapheme, disabled mode 2027" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print right margin wrap" { + var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); - // https://github.com/mitchellh/ghostty/issues/289 - // 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 6 cells taken up - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.printString("XY"); - // 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 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 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 cell = row.getCell(3); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - } - { - 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 cell = row.getCell(5); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1234X6789\n Y", str); } } -// X -test "Terminal: print multicodepoint grapheme, mode 2027" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print right margin outside" { + var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - // https://github.com/mitchellh/ghostty/issues/289 - // 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 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, 2), t.screen.cursor.x); + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 6); + try t.printString("XY"); - // 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 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 str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("12345XY89", str); } } -// X -test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: print right margin outside wrap" { + var t = try init(testing.allocator, 10, 5); 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); + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 10); + try t.printString("XY"); - // 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 cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456789X\n Y", str); } } -// X -test "Terminal: print invalid VS16 grapheme" { +test "Terminal: linefeed and carriage return" { 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); - - // 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 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)); - } + // Basic grid writing + for ("hello") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("world") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello\nworld", str); } } -// X -test "Terminal: print invalid VS16 with second char" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: linefeed unsets pending wrap" { + var t = try init(testing.allocator, 5, 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('y'); - - // 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, 2), 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 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 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)); - } + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap == true); + try t.linefeed(); + try testing.expect(t.screen.cursor.pending_wrap == false); } -// X -test "Terminal: soft wrap" { - var t = try init(testing.allocator, 3, 80); +test "Terminal: linefeed mode automatic carriage return" { + var t = try init(testing.allocator, 10, 10); 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); + t.modes.set(.linefeed, true); + try t.printString("123456"); + try t.linefeed(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("hel\nlo", str); + try testing.expectEqualStrings("123456\nX", str); } } -// X -test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); +test "Terminal: carriage return unsets pending wrap" { + var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); - t.markSemanticPrompt(.prompt); + // Basic grid writing 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); - } + try testing.expect(t.screen.cursor.pending_wrap == true); + t.carriageReturn(); + try testing.expect(t.screen.cursor.pending_wrap == false); } -// X -test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: carriage return origin mode moves to left margin" { + var t = try init(testing.allocator, 5, 80); 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); - } + t.modes.set(.origin, true); + t.screen.cursor.x = 0; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } -// X -test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: carriage return left of left margin moves to zero" { + var t = try init(testing.allocator, 5, 80); 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); + t.screen.cursor.x = 1; + t.scrolling_region.left = 2; + t.carriageReturn(); + 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("AAAAA", str); - } +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); - // 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); - } + t.screen.cursor.x = 3; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } -// X -test "Terminal: disabled wraparound with wide grapheme and half space" { - var t = try init(testing.allocator, 5, 5); +test "Terminal: backspace" { + var t = try init(testing.allocator, 80, 80); 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 + // BS + for ("hello") |c| try t.print(c); + t.backspace(); + try t.print('y'); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - + try testing.expectEqual(@as(usize, 5), 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); + try testing.expectEqualStrings("helly", str); } } -// X -test "Terminal: print writes to bottom if scrolled" { - var t = try init(testing.allocator, 5, 2); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tabs" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - // Basic grid writing - for ("hello") |c| try t.print(c); - t.setCursorPos(0, 0); + // HT + try t.print('1'); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); - // Make newlines so we create scrollback - // 3 pushes hello off the screen - try t.index(); - try t.index(); - try t.index(); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } + // HT + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - // Scroll to the top - try t.scrollViewport(.{ .top = {} }); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello", str); - } + // HT at the end + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); +} - // Type +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); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y, 9); + try t.horizontalTab(); try t.print('A'); - try t.scrollViewport(.{ .bottom = {} }); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA", str); + try testing.expectEqualStrings(" X A", str); } } -// X -test "Terminal: print charset" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tabs with right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(t.screen.cursor.y, 1); + try t.print('X'); + try t.horizontalTab(); + try t.print('A'); - // Basic grid writing - try t.print('`'); - t.configureCharset(.G0, .utf8); - try t.print('`'); - t.configureCharset(.G0, .ascii); - try t.print('`'); - t.configureCharset(.G0, .dec_special); - try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("```◆", str); + try testing.expectEqualStrings("X A", str); } } -// X -test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tabs back" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); + // Edge of screen + t.setCursorPos(t.screen.cursor.y, 20); - // Basic grid writing - t.configureCharset(.G0, .dec_special); - try t.print('`'); - try t.print(0x1F600); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("◆ ", str); - } + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + + // HT + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -// X -test "Terminal: print invoke charset" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tabs back starting on tabstop" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - t.configureCharset(.G1, .dec_special); + t.setCursorPos(t.screen.cursor.y, 9); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y, 9); + try t.horizontalTabBack(); + try t.print('A'); - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, false); - try t.print('`'); - try t.print('`'); - t.invokeCharset(.GL, .G0, false); - try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆◆`", str); + try testing.expectEqualStrings("A X", str); } } -// X -test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tabs with left margin in origin mode" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - t.configureCharset(.G1, .dec_special); + t.modes.set(.origin, true); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(1, 2); + try t.print('X'); + try t.horizontalTabBack(); + try t.print('A'); - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, true); - try t.print('`'); - try t.print('`'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆`", str); + try testing.expectEqualStrings(" AX", str); } } -// X -test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); +test "Terminal: horizontal tab back with cursor before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, 20, 5); + defer t.deinit(alloc); - try t.printString("123456789"); + t.modes.set(.origin, true); + t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 5); - try t.printString("XY"); + t.setLeftAndRightMargin(5, 0); + try t.restoreCursor(); + try t.horizontalTabBack(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("1234X6789\n Y", str); + try testing.expectEqualStrings("X", str); } } -// X -test "Terminal: print right margin outside" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); +test "Terminal: cursorPos resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 6); - try t.printString("XY"); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(1, 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("12345XY89", str); + try testing.expectEqualStrings("XBCDE", str); } } -// X -test "Terminal: print right margin outside wrap" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); +test "Terminal: cursorPos off the screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 10); - try t.printString("XY"); + t.setCursorPos(500, 500); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("123456789X\n Y", str); + try testing.expectEqualStrings("\n\n\n\n X", str); } } -// X -test "Terminal: linefeed and carriage return" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); +test "Terminal: cursorPos relative to origin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); - // Basic grid writing - for ("hello") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("world") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), 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); - try testing.expectEqualStrings("hello\nworld", str); + try testing.expectEqualStrings("\n\nX", str); } } -// X -test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); - try t.linefeed(); - 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); +test "Terminal: cursorPos relative to origin with left/right" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - // Basic grid writing - t.modes.set(.linefeed, true); - try t.printString("123456"); - try t.linefeed(); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); try t.print('X'); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("123456\nX", str); + try testing.expectEqualStrings("\n\n X", str); } } -// X -test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); - t.carriageReturn(); - 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); +test "Terminal: cursorPos limits with full scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - t.modes.set(.origin, true); - t.screen.cursor.x = 0; + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; t.scrolling_region.left = 2; - t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(500, 500); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n X", str); + } } -// X -test "Terminal: carriage return left of left margin moves to zero" { - var t = try init(testing.allocator, 5, 80); +// 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); - t.screen.cursor.x = 1; - t.scrolling_region.left = 2; - t.carriageReturn(); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); -} + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -// 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); + // Setting it to 0 should keep it zero (1 based) + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - t.screen.cursor.x = 3; - t.scrolling_region.left = 2; - t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); -} + // Should clamp to size + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); -// X -test "Terminal: backspace" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); + // Should reset pending wrap + t.setCursorPos(0, 80); + try t.print('c'); + try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(0, 80); + try testing.expect(!t.screen.cursor.pending_wrap); - // BS - for ("hello") |c| try t.print(c); - t.backspace(); - try t.print('y'); - 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); - try testing.expectEqualStrings("helly", str); - } -} + // Origin mode + t.modes.set(.origin, true); -// X -test "Terminal: horizontal tabs" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); + // No change without a scroll region + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - // HT - try t.print('1'); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + // Set the scroll region + t.setTopAndBottomMargin(10, t.rows); + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - // HT - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + t.setCursorPos(1, 1); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - // HT at the end - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + t.setCursorPos(100, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + + t.setTopAndBottomMargin(10, 11); + t.setCursorPos(2, 0); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } -// X -test "Terminal: horizontal tabs starting on tabstop" { +test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.screen.cursor.x = 8; - try t.print('X'); - t.screen.cursor.x = 8; - try t.horizontalTab(); - 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(0, 0); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -// X -test "Terminal: horizontal tabs with right margin" { +test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.screen.cursor.x = 0; - try t.print('X'); - try t.horizontalTab(); - 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(2, 0); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X A", str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); } } -// X -test "Terminal: horizontal tabs back" { +test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // Edge of screen - t.screen.cursor.x = 19; - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(1, 2); + t.scrollDown(1); - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try t.horizontalTabBack(); - 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("\nABC\nGHI", str); + } } -// X -test "Terminal: horizontal tabs back starting on tabstop" { +test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.screen.cursor.x = 8; - try t.print('X'); - t.screen.cursor.x = 8; - try t.horizontalTabBack(); - 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(2, 2); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A X", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -// X -test "Terminal: horizontal tabs with left margin in origin mode" { +test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.screen.cursor.x = 3; - try t.print('X'); - try t.horizontalTabBack(); - 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.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(0, 0); + t.eraseChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" AX", str); + try testing.expectEqualStrings(" BC\nDEF\nGHI", str); } } -// X -test "Terminal: horizontal tab back with cursor before left margin" { +test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.saveCursor(); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(5, 0); - t.restoreCursor(); - try t.horizontalTabBack(); - try t.print('X'); + t.setLeftAndRightMargin(2, 0); + 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); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); } } -// X -test "Terminal: cursorPos resets wrap" { +test "Terminal: setLeftAndRightMargin left and right" { 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.setCursorPos(1, 1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE", str); + try testing.expectEqualStrings(" C\nABF\nDEI\nGH", str); } } -// X -test "Terminal: cursorPos off the screen" { +test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setCursorPos(500, 500); - try t.print('X'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 2); + t.setCursorPos(1, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n\n X", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -// X -test "Terminal: cursorPos relative to origin" { +test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.modes.set(.origin, true); - t.setCursorPos(1, 1); - try t.print('X'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, false); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -// X -test "Terminal: cursorPos relative to origin with left/right" { +test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(1, 1); - try t.print('X'); + 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("\n\n X", str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); } } -// X -test "Terminal: cursorPos limits with full scroll region" { +test "Terminal: insertLines colors with bg color" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(500, 500); - try t.print('X'); + 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("\n\n\n X", str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); } -} -// X -test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); + 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); + } +} - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +test "Terminal: insertLines handles style refs" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); - // Setting it to 0 should keep it zero (1 based) - t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); - // Should clamp to size - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + // For the line being deleted, create a refcounted style + try t.setAttribute(.{ .bold = {} }); + try t.printString("GHI"); + try t.setAttribute(.{ .unset = {} }); - // Should reset pending wrap - t.setCursorPos(0, 80); - try t.print('c'); - try testing.expect(t.screen.cursor.pending_wrap); - t.setCursorPos(0, 80); - try testing.expect(!t.screen.cursor.pending_wrap); + // 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)); - // Origin mode - t.modes.set(.origin, true); + t.setCursorPos(2, 2); + t.insertLines(1); - // No change without a scroll region - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF", str); + } - // Set the scroll region - t.setTopAndBottomMargin(10, t.rows); - t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} - t.setCursorPos(1, 1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); +test "Terminal: insertLines outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); - t.setCursorPos(100, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + 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); - t.setTopAndBottomMargin(10, 11); - t.setCursorPos(2, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } } -// X -test "Terminal: setTopAndBottomMargin simple" { +test "Terminal: insertLines top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3349,163 +3754,204 @@ test "Terminal: setTopAndBottomMargin simple" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.setTopAndBottomMargin(0, 0); - try t.scrollDown(1); + 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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); } } -// X -test "Terminal: setTopAndBottomMargin top only" { +test "Terminal: insertLines (legacy test)" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 5); defer t.deinit(alloc); - try t.printString("ABC"); + // Initial value + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + try t.print('B'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 0); - try t.scrollDown(1); + 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("ABC\n\nDEF\nGHI", str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); } } -// X -test "Terminal: setTopAndBottomMargin top and bottom" { +test "Terminal: insertLines zero" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 5); defer t.deinit(alloc); - try t.printString("ABC"); + // 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.printString("DEF"); + try t.print('B'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); + 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); - try t.scrollDown(1); + 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("\nABC\nGHI", str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); } } -// X -test "Terminal: setTopAndBottomMargin top equal to bottom" { +test "Terminal: insertLines more than remaining" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 2, 5); defer t.deinit(alloc); - try t.printString("ABC"); + // Initial value + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + try t.print('B'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 2); - try t.scrollDown(1); + 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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("A", str); } } -// X -test "Terminal: setLeftAndRightMargin simple" { +test "Terminal: insertLines resets wrap" { 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(0, 0); - t.eraseChars(1); + 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(" BC\nDEF\nGHI", str); + try testing.expectEqualStrings("B\nABCDE", str); } } -// X -test "Terminal: setLeftAndRightMargin left only" { +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(); - try t.printString("DEF"); + + // 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 0); - 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.setCursorPos(2, 2); + t.insertLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); } } -// X -test "Terminal: setLeftAndRightMargin left and right" { +test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - try t.printString("ABC"); + try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); + 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(" C\nABF\nDEI\nGH", str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); } } -// X -test "Terminal: setLeftAndRightMargin left equal right" { +test "Terminal: scrollUp simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3517,20 +3963,20 @@ test "Terminal: setLeftAndRightMargin left equal right" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); + 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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("DEF\nGHI", str); } } -// X -test "Terminal: setLeftAndRightMargin mode 69 unset" { +test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3542,178 +3988,129 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, false); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); + 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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("ABC\nGHI", str); } } -// X -test "Terminal: deleteLines" { +test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 10, 10); 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.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.print('D'); - - t.cursorUp(2); - try t.deleteLines(1); - - try t.print('E'); + try t.printString("DEF456"); t.carriageReturn(); try t.linefeed(); - - // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + 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("A\nE\nD", str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -// X -test "Terminal: deleteLines with scroll region" { +test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // Initial value + t.setCursorPos(1, 5); try t.print('A'); - t.carriageReturn(); - try t.linefeed(); + t.setCursorPos(2, 5); try t.print('B'); - t.carriageReturn(); - try t.linefeed(); + t.setCursorPos(3, 5); try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - try t.deleteLines(1); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + t.scrollUp(1); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("E\nC\n\nD", str); + try testing.expectEqualStrings(" B\n C\n\nX", str); } } -// X -test "Terminal: deleteLines with scroll region, large count" { +test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 5, 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - try t.deleteLines(5); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + 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("E\n\n\nD", str); + try testing.expectEqualStrings("top", str); } } -// X -test "Terminal: deleteLines with scroll region, cursor outside of region" { +test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, 5, 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.deleteLines(1); + 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("A\nB\nC\nD", str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); } } -// X -test "Terminal: deleteLines resets wrap" { +test "Terminal: scrollDown simple" { 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.deleteLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + 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("B", str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); } } -// X -test "Terminal: deleteLines simple" { +test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); @@ -3725,18 +4122,21 @@ test "Terminal: deleteLines simple" { t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - try t.deleteLines(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("ABC\nGHI", str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); } } -// X -test "Terminal: deleteLines left/right scroll region" { +test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); @@ -3751,36 +4151,19 @@ 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); + 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("ABC123\nDHI756\nG 89", str); - } -} - -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()); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); } } -// X -test "Terminal: deleteLines left/right scroll region from top" { +test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); @@ -3794,273 +4177,270 @@ test "Terminal: deleteLines left/right scroll region from top" { try t.printString("GHI789"); t.scrolling_region.left = 1; t.scrolling_region.right = 3; - t.setCursorPos(1, 2); - try t.deleteLines(1); + 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("AEF423\nDHI756\nG 89", str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); } } -// X -test "Terminal: deleteLines left/right scroll region high count" { +test "Terminal: scrollDown preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 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.deleteLines(100); + 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("ABC123\nD 56\nG 89", str); + try testing.expectEqualStrings("\n A\n B\nX C", str); } } -// X -test "Terminal: insertLines simple" { +test "Terminal: eraseChars simple operation" { 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); + 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("ABC\n\nDEF\nGHI", str); + try testing.expectEqualStrings("X C", str); } } -// X -test "Terminal: insertLines outside of scroll region" { +test "Terminal: eraseChars minimum one" { 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); + 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("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("XBC", str); } } -// X -test "Terminal: insertLines top/bottom scroll region" { +test "Terminal: eraseChars beyond screen edge" { 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); + 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("ABC\n\nDEF\n123", str); + try testing.expectEqualStrings(" A", str); } } -// X -test "Terminal: insertLines left/right scroll region" { +test "Terminal: eraseChars wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); 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); + 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("ABC123\nD 56\nGEF489\n HI7", str); + try testing.expectEqualStrings("X BC", str); } } -// X -test "Terminal: insertLines" { +test "Terminal: eraseChars resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 5, 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'); + 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'); - // Move to row 2 - t.setCursorPos(2, 1); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} - // Insert two lines - try t.insertLines(2); +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("A\n\n\nB\nC", 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); + } } } -// X -test "Terminal: insertLines zero" { +test "Terminal: eraseChars handles refcounted styles" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - // This should do nothing + 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); - try t.insertLines(0); + t.eraseChars(2); + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -// X -test "Terminal: insertLines with scroll region" { +test "Terminal: eraseChars protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 2, 6); + var t = try init(alloc, 5, 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'); - - t.setTopAndBottomMargin(1, 2); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - try t.insertLines(1); - - try t.print('X'); + t.eraseChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + try testing.expectEqualStrings("ABC", str); } } -// X -test "Terminal: insertLines more than remaining" { +test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 5, 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); + 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("A", str); + try testing.expectEqualStrings(" C", str); } } -// X -test "Terminal: insertLines resets wrap" { +test "Terminal: eraseChars protected attributes ignored with dec set" { 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.print('B'); + 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("B\nABCDE", str); + try testing.expectEqualStrings(" C", str); } } -// X test "Terminal: reverseIndex" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4074,7 +4454,7 @@ test "Terminal: reverseIndex" { t.carriageReturn(); try t.linefeed(); try t.print('C'); - try t.reverseIndex(); + t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); @@ -4088,7 +4468,6 @@ test "Terminal: reverseIndex" { } } -// X test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4104,13 +4483,13 @@ test "Terminal: reverseIndex from the top" { try t.linefeed(); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('D'); t.carriageReturn(); try t.linefeed(); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('E'); t.carriageReturn(); try t.linefeed(); @@ -4122,7 +4501,6 @@ test "Terminal: reverseIndex from the top" { } } -// X test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 10); @@ -4146,7 +4524,7 @@ test "Terminal: reverseIndex top of scrolling region" { // Set our scroll region t.setTopAndBottomMargin(2, 5); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4156,7 +4534,6 @@ test "Terminal: reverseIndex top of scrolling region" { } } -// X test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4168,7 +4545,7 @@ test "Terminal: reverseIndex top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4178,7 +4555,6 @@ test "Terminal: reverseIndex top of screen" { } } -// X test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4190,7 +4566,7 @@ test "Terminal: reverseIndex not top of screen" { t.setCursorPos(3, 1); try t.print('C'); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); try t.print('X'); { @@ -4200,7 +4576,6 @@ test "Terminal: reverseIndex not top of screen" { } } -// X test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4213,7 +4588,7 @@ test "Terminal: reverseIndex top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(2, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4222,7 +4597,6 @@ test "Terminal: reverseIndex top/bottom margins" { } } -// X test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4235,7 +4609,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { try t.print('C'); t.setTopAndBottomMargin(2, 3); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4244,7 +4618,6 @@ test "Terminal: reverseIndex outside top/bottom margins" { } } -// X test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4258,7 +4631,7 @@ test "Terminal: reverseIndex left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 2); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4267,7 +4640,6 @@ test "Terminal: reverseIndex left/right margins" { } } -// X test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4281,7 +4653,7 @@ test "Terminal: reverseIndex outside left/right margins" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 3); t.setCursorPos(1, 1); - try t.reverseIndex(); + t.reverseIndex(); { const str = try t.plainString(testing.allocator); @@ -4290,7 +4662,6 @@ test "Terminal: reverseIndex outside left/right margins" { } } -// X test "Terminal: index" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4306,7 +4677,6 @@ test "Terminal: index" { } } -// X test "Terminal: index from the bottom" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4326,7 +4696,6 @@ test "Terminal: index from the bottom" { } } -// X test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, 2, 5); @@ -4338,7 +4707,6 @@ test "Terminal: index outside of scrolling region" { 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); @@ -4357,7 +4725,6 @@ test "Terminal: index from the bottom outside of scroll region" { } } -// X test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4374,7 +4741,6 @@ test "Terminal: index no scroll region, top of screen" { } } -// X test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4392,19 +4758,18 @@ test "Terminal: index bottom of primary screen" { } } -// 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.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); try t.index(); { @@ -4412,13 +4777,17 @@ test "Terminal: index bottom of primary screen background sgr" { 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); + 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); } } } -// X test "Terminal: index inside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4436,28 +4805,6 @@ test "Terminal: index inside scroll region" { } } -// 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); @@ -4479,7 +4826,6 @@ test "Terminal: index bottom of primary screen with scroll region" { } } -// X test "Terminal: index outside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -4501,7 +4847,6 @@ test "Terminal: index outside left/right margin" { } } -// X test "Terminal: index inside left/right margin" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -4530,544 +4875,430 @@ test "Terminal: index inside left/right margin" { } } -// X -test "Terminal: DECALN" { +test "Terminal: index bottom of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); try t.print('B'); - try t.decaln(); - - try testing.expectEqual(@as(usize, 0), 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("EE\nEE", str); - } -} - -// X -test "Terminal: decaln reset margins" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); - - // Initial value - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nEEE\nEEE", str); - } -} - -// 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; - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try 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); - } -} - -// X -test "Terminal: insertBlanks" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - + t.setCursorPos(3, 1); 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); + try t.index(); + try t.print('X'); { 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); + try testing.expectEqualStrings("\nA\n X\nB", str); } } -// X -test "Terminal: insertBlanks pushes off end" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +test "Terminal: cursorUp basic" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); + t.setCursorPos(3, 1); try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(2); + t.cursorUp(10); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings(" X\n\nA", str); } } -// X -test "Terminal: insertBlanks more than size" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. +test "Terminal: cursorUp below top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(3, 1); try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(5); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// X -test "Terminal: insertBlanks no scroll region, fits" { - 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); - t.insertBlanks(2); + t.cursorUp(5); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); + try testing.expectEqualStrings("\n X\nA", str); } } -// X -test "Terminal: insertBlanks preserves background sgr" { +test "Terminal: cursorUp above top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); 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.insertBlanks(2); + 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(" ABC", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + try testing.expectEqualStrings("X\n\nA", str); } } -// X -test "Terminal: insertBlanks shift off screen" { +test "Terminal: cursorUp resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); + 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(" X A", str); + try testing.expectEqualStrings("ABCDX", str); } } -// X -test "Terminal: insertBlanks split multi-cell character" { +test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('橋'); - t.setCursorPos(1, 1); - t.insertBlanks(1); + 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(" 123", str); + try testing.expectEqualStrings("A\nB", str); } } -// X -test "Terminal: insertBlanks inside left/right scroll region" { +test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 3); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); + 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(" X A", str); + try testing.expectEqualStrings("ABCXE", str); } } -// X -test "Terminal: insertBlanks outside left/right scroll region" { +test "Terminal: cursorLeft unsets pending wrap state with longer jump" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setCursorPos(1, 4); - for ("ABC") |c| try t.print(c); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; + for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - t.insertBlanks(2); + 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(" ABX", str); + try testing.expectEqualStrings("AXCDE", str); } } -// X -test "Terminal: insertBlanks left/right scroll region large count" { +test "Terminal: cursorLeft reverse wrap with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 1); - t.insertBlanks(140); + 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(" X", str); + try testing.expectEqualStrings("ABCDX", str); } } -// X -test "Terminal: insert mode with space" { +test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 10, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); + 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("hXello", str); + try testing.expectEqualStrings("ABCDX", str); } } -// X -test "Terminal: insert mode doesn't wrap pushed characters" { +test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); + 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("hXell", str); + try testing.expectEqualStrings("ABCDX\n1", str); } } -// X -test "Terminal: insert mode does nothing at the end of the line" { +test "Terminal: cursorLeft reverse wrap with no soft wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.modes.set(.insert, true); + 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("hello\nX", str); + try testing.expectEqualStrings("ABCDE\nX", str); } } -// X -test "Terminal: insert mode with wide characters" { +test "Terminal: cursorLeft reverse wrap before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + 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("h😀el", str); + try testing.expectEqualStrings("\n\nX", str); } } -// X -test "Terminal: insert mode with wide characters at end" { +test "Terminal: cursorLeft extended reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("well") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 + 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("well\n😀", str); + try testing.expectEqualStrings("ABCDX\n1", str); } } -// X -test "Terminal: insert mode pushing off wide character" { +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, 5, 3); defer t.deinit(alloc); - for ("123") |c| try t.print(c); - try t.print('😀'); // 0x1F600 - t.modes.set(.insert, true); - t.setCursorPos(1, 1); + 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("X123", str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); } } -// X -test "Terminal: cursorIsAtPrompt" { +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, 5, 3); 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()); + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.modes.set(.reverse_wrap_extended, true); - // Until we know we're at a prompt again + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); 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()); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); - // Secondary screen is never a prompt - t.alternateScreen(alloc, .{}); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(!t.cursorIsAtPrompt()); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } } -// X -test "Terminal: print wide char with 1-column width" { +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 1, 2); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('😀'); // 0x1F600 + 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: deleteChars" { +test "Terminal: cursorLeft reverse wrap on first row" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - 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 } }; + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); - try t.deleteChars(2); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ADE", str); + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(1, 2); + t.cursorLeft(1000); - const cell = t.screen.getCell(.active, 0, 4); - try testing.expect(!cell.attrs.bold); - } + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); } -// X -test "Terminal: deleteChars zero count" { +test "Terminal: cursorDown basic" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + try t.print('A'); + t.cursorDown(10); + try t.print('X'); - try t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE", str); + try testing.expectEqualStrings("A\n\n\n\n X", str); } } -// X -test "Terminal: deleteChars more than half" { +test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.cursorDown(10); + try t.print('X'); - try t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AE", str); + try testing.expectEqualStrings("A\n\n X", str); } } -// X -test "Terminal: deleteChars more than line width" { +test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); + try t.print('X'); - try t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("A\n\n\n\nX", str); } } -// X -test "Terminal: deleteChars should shift left" { +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); - t.setCursorPos(1, 2); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorDown(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); - try t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ACDE", str); + try testing.expectEqualStrings("ABCDE\n X", str); } } -// X -test "Terminal: deleteChars resets wrap" { +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); - try t.deleteChars(1); + t.cursorRight(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('X'); @@ -5078,2402 +5309,2394 @@ test "Terminal: deleteChars resets wrap" { } } -// X -test "Terminal: deleteChars simple operation" { +test "Terminal: cursorRight to the edge of screen" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.setCursorPos(1, 3); - try t.deleteChars(2); + t.cursorRight(100); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB23", str); + try testing.expectEqualStrings(" X", str); } } -// X -test "Terminal: deleteChars background sgr" { +test "Terminal: cursorRight left of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - try t.printString("ABC123"); - t.setCursorPos(1, 3); - t.screen.cursor.pen = pen; - try t.deleteChars(2); + 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("AB23", str); - for (t.cols - 2..t.cols) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + try testing.expectEqualStrings(" X", str); } } -// X -test "Terminal: deleteChars outside scroll region" { +test "Terminal: cursorRight right of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(2); - try testing.expect(t.screen.cursor.pending_wrap); + 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("ABC123", str); + try testing.expectEqualStrings(" X", str); } } -// X -test "Terminal: deleteChars inside scroll region" { +test "Terminal: deleteLines simple" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 4); - try t.deleteChars(1); + 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("ABC2 3", str); + try testing.expectEqualStrings("ABC\nGHI", str); } } -// X -test "Terminal: deleteChars split wide character" { +test "Terminal: deleteLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("A橋123"); - t.setCursorPos(1, 3); - try t.deleteChars(1); + 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("A 123", 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); } } -// X -test "Terminal: deleteChars split wide character tail" { +test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - t.setCursorPos(1, t.cols - 1); - try t.print(0x6A4B); // 橋 + // Initial value + try t.print('A'); t.carriageReturn(); - try t.deleteChars(t.cols - 1); - try t.print('0'); + 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.cursorUp(2); + t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("0", str); + try testing.expectEqualStrings("A\nE\nD", str); } } -// X -test "Terminal: eraseChars resets pending wrap" { +test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); 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'); + // 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings("E\nC\n\nD", str); } } // X -test "Terminal: eraseChars resets wrap" { +test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - for ("ABCDE123") |c| try t.print(c); - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); - } + // 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.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); - t.eraseChars(1); + t.deleteLines(5); - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); - } - try t.print('X'); + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE\n123", str); + try testing.expectEqualStrings("E\n\n\nD", str); } } // X -test "Terminal: eraseChars simple operation" { +test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 80, 80); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseChars(2); - try t.print('X'); + // 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.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X C", str); + try testing.expectEqualStrings("A\nB\nC\nD", str); } } -// X -test "Terminal: eraseChars minimum one" { +test "Terminal: deleteLines resets wrap" { 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'); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.deleteLines(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("XBC", str); + try testing.expectEqualStrings("B", str); } } -// X -test "Terminal: eraseChars beyond screen edge" { +test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseChars(10); + 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.deleteLines(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); + try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); } } -// X -test "Terminal: eraseChars preserves background sgr" { +test "Terminal: deleteLines left/right scroll region from top" { 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); + 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, 2); + t.deleteLines(1); { 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); - } + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); } } -// X -test "Terminal: eraseChars wide character" { +test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); 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'); + 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.deleteLines(100); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X BC", str); + try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); } } -// X -test "Terminal: eraseChars protected attributes respected with iso" { +test "Terminal: default style is empty" { 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); + try t.print('A'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", 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: eraseChars protected attributes ignored with dec most recent" { +test "Terminal: bold style" { 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); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", 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.expect(cell.style_id != 0); + try testing.expect(t.screen.cursor.style_ref.?.* > 0); } } -// X -test "Terminal: eraseChars protected attributes ignored with dec set" { +test "Terminal: garbage collect overwritten" { 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); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); t.setCursorPos(1, 1); - t.eraseChars(2); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", 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 -// 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" { +test "Terminal: do not garbage collect old styles in use" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 5); 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 + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + 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); + } + + // 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 -// 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" { +test "Terminal: print with style marks the row as styled" { const alloc = testing.allocator; - const cols = 70; - const rows = 23; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 5, 5); 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); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.styled); + } } -// X -// https://github.com/mitchellh/ghostty/issues/1343 -test "Terminal: resize with wraparound off" { +test "Terminal: DECALN" { const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 2, 2); 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); + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + try t.decaln(); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01", str); + try testing.expectEqual(@as(usize, 0), 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("EE\nEE", str); + } } -// X -test "Terminal: resize with wraparound on" { +test "Terminal: decaln reset margins" { const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, 3, 3); 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); + // Initial value + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01\n23", str); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + } } -// X -test "Terminal: saveCursor" { +test "Terminal: decaln preserves color" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - t.screen.cursor.pen.attrs.bold = true; - t.screen.charset.gr = .G3; + // Initial value + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); t.modes.set(.origin, true); - t.saveCursor(); - t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - 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)); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + 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); + } } -// X -test "Terminal: saveCursor with screen change" { +test "Terminal: insertBlanks" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.screen.cursor.pen.attrs.bold = true; - t.screen.cursor.x = 2; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - 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.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; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - t.primaryScreen(alloc, .{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.pen.attrs.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } } -// X -test "Terminal: saveCursor position" { +test "Terminal: insertBlanks pushes off end" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 3, 2); defer t.deinit(alloc); - t.setCursorPos(1, 5); try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); try t.print('B'); - t.restoreCursor(); - try t.print('X'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B AX", str); + try testing.expectEqualStrings(" A", str); } } -// X -test "Terminal: saveCursor pending wrap state" { +test "Terminal: insertBlanks more than size" { + // NOTE: this is not verified with conformance tests, so these + // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); defer t.deinit(alloc); - t.setCursorPos(1, 5); try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); try t.print('B'); - t.restoreCursor(); - try t.print('X'); + try t.print('C'); + t.setCursorPos(1, 1); + t.insertBlanks(5); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("B A\nX", str); + try testing.expectEqualStrings("", str); } } -// X -test "Terminal: saveCursor origin mode" { +test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.modes.set(.origin, true); - t.saveCursor(); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setTopAndBottomMargin(2, 4); - t.restoreCursor(); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); + try testing.expectEqualStrings(" ABC", str); } } -// X -test "Terminal: saveCursor resize" { +test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.setCursorPos(1, 10); - t.saveCursor(); - try t.resize(alloc, 5, 5); - t.restoreCursor(); - try t.print('X'); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + 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(" X", 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); } } -// X -test "Terminal: saveCursor protected pen" { +test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 10); defer t.deinit(alloc); - t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.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); + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } } -// X -test "Terminal: setProtectedMode" { +test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, 5, 10); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); + for ("123") |c| try t.print(c); + try t.print('橋'); + t.setCursorPos(1, 1); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 123", str); + } } -// X -test "Terminal: eraseLine simple erase right" { +test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; t.setCursorPos(1, 3); - t.eraseLine(.right, false); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.insertBlanks(2); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); + try testing.expectEqualStrings(" X A", str); } } -// X -test "Terminal: eraseLine resets pending wrap" { +test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 4); + for ("ABC") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; try testing.expect(t.screen.cursor.pending_wrap); - t.eraseLine(.right, false); + t.insertBlanks(2); try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDB", str); + try testing.expectEqualStrings(" ABX", str); } } -// X -test "Terminal: eraseLine resets wrap" { +test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - for ("ABCDE123") |c| try t.print(c); + t.modes.set(.origin, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 1); + t.insertBlanks(140); + try t.print('X'); + { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); } +} + +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.eraseLine(.right, false); + t.insertBlanks(4); { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); } - try t.print('X'); + + // 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("X\n123", str); + try testing.expectEqualStrings(" A👨‍👩‍👧", str); } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); } -// X -test "Terminal: eraseLine right preserves background sgr" { +test "Terminal: insert mode with space" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 2); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - for ("ABCDE") |c| try t.print(c); + for ("hello") |c| try t.print(c); t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; - t.eraseLine(.right, false); + t.modes.set(.insert, true); + try t.print('X'); { const str = try t.plainString(testing.allocator); 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); - } + try testing.expectEqualStrings("hXello", str); } } -// X -test "Terminal: eraseLine right wide character" { +test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseLine(.right, false); + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); + try testing.expectEqualStrings("hXell", str); } } -// X -test "Terminal: eraseLine right protected attributes respected with iso" { +test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.right, false); + for ("hello") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("hello\nX", str); } } -// X -test "Terminal: eraseLine right protected attributes ignored with dec most recent" { +test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); + for ("hello") |c| try t.print(c); t.setCursorPos(1, 2); - t.eraseLine(.right, false); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("h😀el", str); } } -// X -test "Terminal: eraseLine right protected attributes ignored with dec set" { +test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.right, false); + for ("well") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('😀'); // 0x1F600 { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("well\n😀", str); } } -// X -test "Terminal: eraseLine right protected requested" { +test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 2); defer t.deinit(alloc); - for ("12345678") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); + for ("123") |c| try t.print(c); + try t.print('😀'); // 0x1F600 + t.modes.set(.insert, true); + t.setCursorPos(1, 1); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseLine(.right, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("123 X", str); + try testing.expectEqualStrings("X123", str); } } -// X -test "Terminal: eraseLine simple erase left" { +test "Terminal: deleteChars" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); + t.setCursorPos(1, 2); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); + try testing.expectEqualStrings("ADE", str); } } -// X -test "Terminal: eraseLine left resets wrap" { +test "Terminal: deleteChars zero count" { 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.eraseLine(.left, false); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); + t.setCursorPos(1, 2); + t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" B", str); + try testing.expectEqualStrings("ABCDE", str); } } -// X -test "Terminal: eraseLine left preserves background sgr" { +test "Terminal: deleteChars more than half" { 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; - t.eraseLine(.left, false); + t.deleteChars(3); { const str = try t.plainString(testing.allocator); 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); - } + try testing.expectEqualStrings("AE", str); } } -// X -test "Terminal: eraseLine left wide character" { +test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); + try testing.expectEqualStrings("A", str); } } -// X -test "Terminal: eraseLine left protected attributes respected with iso" { +test "Terminal: deleteChars should shift left" { 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.eraseLine(.left, false); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("ACDE", str); } } -// X -test "Terminal: eraseLine left protected attributes ignored with dec most recent" { +test "Terminal: deleteChars resets wrap" { 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, 2); - t.eraseLine(.left, false); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.deleteChars(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(" C", str); + try testing.expectEqualStrings("ABCDX", str); } } -// X -test "Terminal: eraseLine left protected attributes ignored with dec set" { +test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - t.setProtectedMode(.dec); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 2); - t.eraseLine(.left, false); + try t.printString("ABC123"); + t.setCursorPos(1, 3); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); + try testing.expectEqualStrings("AB23", str); } } -// X -test "Terminal: eraseLine left protected requested" { +test "Terminal: deleteChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 10, 10); defer t.deinit(alloc); - 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.eraseLine(.left, true); + for ("ABC123") |c| try t.print(c); + t.setCursorPos(1, 3); + 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(" X 9", 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); } } -// X -test "Terminal: eraseLine complete preserves background sgr" { +test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); 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; - t.eraseLine(.complete, false); + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screen.cursor.pending_wrap); + t.deleteChars(2); + try testing.expect(t.screen.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); 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); - } + try testing.expectEqualStrings("ABC123", str); } } -// X -test "Terminal: eraseLine complete protected attributes respected with iso" { +test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.eraseLine(.complete, false); + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 4); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); + try testing.expectEqualStrings("ABC2 3", str); } } -// X -test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { +test "Terminal: deleteChars split wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 6, 10); defer t.deinit(alloc); - t.setProtectedMode(.iso); - for ("ABC") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(1, 2); - t.eraseLine(.complete, false); + try t.printString("A橋123"); + t.setCursorPos(1, 3); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings("A 123", str); } } -// X -test "Terminal: eraseLine complete protected attributes ignored with dec set" { +test "Terminal: deleteChars split wide character tail" { 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, 2); - t.eraseLine(.complete, false); + t.setCursorPos(1, t.cols - 1); + try t.print(0x6A4B); // 橋 + t.carriageReturn(); + t.deleteChars(t.cols - 1); + try t.print('0'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings("0", str); } } -// X -test "Terminal: eraseLine complete protected requested" { +test "Terminal: saveCursor" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + t.saveCursor(); + t.screen.charset.gr = .G0; + try t.setAttribute(.{ .unset = {} }); + t.modes.set(.origin, false); + 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)); +} + +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.x = 2; + t.screen.charset.gr = .G3; + t.modes.set(.origin, true); + 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.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; + try t.setAttribute(.{ .reset_bold = {} }); + t.modes.set(.origin, false); + t.primaryScreen(.{ + .cursor_save = true, + .clear_on_enter = true, + }); + try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); +} + +test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); defer t.deinit(alloc); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); + try testing.expectEqualStrings("B AX", str); } } -// X -test "Terminal: eraseDisplay simple erase below" { +test "Terminal: saveCursor pending wrap state" { 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); + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + try t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); + try testing.expectEqualStrings("B A\nX", str); } } -// X -test "Terminal: eraseDisplay erase below preserves SGR bg" { +test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 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); + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setTopAndBottomMargin(2, 4); + try t.restoreCursor(); + try t.print('X'); { 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); - } + try testing.expectEqualStrings("X", str); } } -// X -test "Terminal: eraseDisplay below split multi-cell" { +test "Terminal: saveCursor resize" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 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); + t.setCursorPos(1, 10); + t.saveCursor(); + try t.resize(alloc, 5, 5); + try t.restoreCursor(); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AB橋C\nDE", str); + try testing.expectEqualStrings(" X", str); } } -// X -test "Terminal: eraseDisplay below protected attributes respected with iso" { +test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 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); - } + try testing.expect(t.screen.cursor.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); } -// X -test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { +test "Terminal: setProtectedMode" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 3); defer t.deinit(alloc); + try testing.expect(!t.screen.cursor.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screen.cursor.protected); 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); + try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.dec); + try testing.expect(t.screen.cursor.protected); 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); - } + try testing.expect(!t.screen.cursor.protected); } -// X -test "Terminal: eraseDisplay below protected attributes ignored with dec set" { +test "Terminal: eraseLine simple erase right" { 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); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); + try testing.expectEqualStrings("AB", str); } } -// X -test "Terminal: eraseDisplay simple erase above" { +test "Terminal: eraseLine resets pending wrap" { 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); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.right, false); + 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("\n F\nGHI", str); + try testing.expectEqualStrings("ABCDB", str); } } -// X -test "Terminal: eraseDisplay below protected attributes respected with force" { +test "Terminal: eraseLine resets wrap" { 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); - + for ("ABCDE123") |c| try t.print(c); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.wrap); } -} - -// 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); + t.setCursorPos(1, 1); + t.eraseLine(.right, 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); - } + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(!list_cell.row.wrap); } -} - -// 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); + try t.print('X'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGH橋I", str); + try testing.expectEqualStrings("X\n123", str); } } -// X -test "Terminal: eraseDisplay above protected attributes respected with iso" { +test "Terminal: eraseLine right preserves background sgr" { 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); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", 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); + } } } -// 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); - +test "Terminal: eraseLine right wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseLine(.right, false); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); + try testing.expectEqualStrings("AB", str); } } -// X -test "Terminal: eraseDisplay above protected attributes ignored with dec set" { +test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setProtectedMode(.dec); + 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); + t.setCursorPos(1, 1); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); + try testing.expectEqualStrings("ABC", str); } } -// X -test "Terminal: eraseDisplay above protected attributes respected with force" { +test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - t.setProtectedMode(.dec); + 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, true); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + try testing.expectEqualStrings("A", 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" { +test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 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); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.right, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X", str); + try testing.expectEqualStrings("A", str); } } -// X -test "Terminal: eraseDisplay protected below" { +test "Terminal: eraseLine right protected requested" { 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); + for ("12345678") |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); + t.eraseLine(.right, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n123 X", str); + try testing.expectEqualStrings("123 X", str); } } -// X -test "Terminal: eraseDisplay protected above" { +test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - t.eraseDisplay(alloc, .scroll_complete, false); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); + try testing.expectEqualStrings(" DE", str); } } -// X -test "Terminal: eraseDisplay scroll complete" { +test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 3); + var t = try init(alloc, 5, 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, 8); - t.eraseDisplay(alloc, .above, true); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseLine(.left, false); + 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("\n X 9", str); + try testing.expectEqualStrings(" B", str); } } -// X -test "Terminal: cursorLeft no wrap" { +test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.cursorLeft(10); + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB", 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); + } } } -// X -test "Terminal: cursorLeft unsets pending wrap state" { +test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 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'); + for ("AB") |c| try t.print(c); + try t.print('橋'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCXE", str); + try testing.expectEqualStrings(" DE", str); } } -// X -test "Terminal: cursorLeft unsets pending wrap state with longer jump" { +test "Terminal: eraseLine left protected attributes respected with iso" { 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'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AXCDE", str); + try testing.expectEqualStrings("ABC", str); } } -// X -test "Terminal: cursorLeft reverse wrap with pending wrap state" { +test "Terminal: eraseLine left protected attributes ignored with dec most recent" { 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'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings(" C", str); } } -// X -test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { +test "Terminal: eraseLine left protected attributes ignored with dec set" { 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'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.left, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); + try testing.expectEqualStrings(" C", str); } } -// X -test "Terminal: cursorLeft reverse wrap" { +test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 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); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseLine(.left, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); + try testing.expectEqualStrings(" X 9", str); } } -// X -test "Terminal: cursorLeft reverse wrap with no soft wrap" { +test "Terminal: eraseLine complete preserves background sgr" { 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'); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\nX", 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); + } } } -// X -test "Terminal: cursorLeft reverse wrap before left margin" { +test "Terminal: eraseLine complete protected attributes respected with iso" { 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'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); + try testing.expectEqualStrings("ABC", str); } } -// X -test "Terminal: cursorLeft extended reverse wrap" { +test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { 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'); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); + try testing.expectEqualStrings("", str); } } -// X -test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { +test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + 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(1 + t.cols + 1); - try t.print('X'); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.eraseLine(.complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings("", str); } } -// X -test "Terminal: cursorLeft extended reverse wrap is priority if both set" { +test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, 10, 5); 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); + 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.eraseLine(.complete, true); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); + try testing.expectEqualStrings(" X", str); } } -// X -test "Terminal: cursorLeft extended reverse wrap above top scroll region" { +test "Terminal: tabClear single" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 30, 5); defer t.deinit(alloc); - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); + try t.horizontalTab(); + t.tabClear(.current); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); +} - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(2, 1); - t.cursorLeft(1000); +test "Terminal: tabClear all" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 5); + defer t.deinit(alloc); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + t.tabClear(.all); + t.setCursorPos(1, 1); + try t.horizontalTab(); + try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); } -// X -test "Terminal: cursorLeft reverse wrap on first row" { +test "Terminal: printRepeat simple" { 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 t.printString("A"); + try t.printRepeat(1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AA", str); + } } -// X -test "Terminal: cursorDown basic" { +test "Terminal: printRepeat wrap" { 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'); + try t.printString(" A"); + try t.printRepeat(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\n X", str); + try testing.expectEqualStrings(" A\nA", str); } } -// X -test "Terminal: cursorDown above bottom scroll margin" { +test "Terminal: printRepeat no previous character" { 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'); + try t.printRepeat(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n X", str); + try testing.expectEqualStrings("", str); } } -// X -test "Terminal: cursorDown below bottom scroll margin" { +test "Terminal: printAttributes" { 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'); + var storage: [64]u8 = undefined; { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\nX", str); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;38:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.faint); + try t.setAttribute(.italic); + try t.setAttribute(.{ .underline = .single }); + try t.setAttribute(.blink); + try t.setAttribute(.inverse); + try t.setAttribute(.invisible); + try t.setAttribute(.strikethrough); + try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + } + + { + try t.setAttribute(.{ .underline = .single }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;4", buf); + } + + { + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0", buf); } } -// X -test "Terminal: cursorDown resets wrap" { +test "Terminal: eraseDisplay simple erase below" { 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'); + 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("ABCDE\n X", str); + try testing.expectEqualStrings("ABC\nD", str); } } -// X -test "Terminal: cursorUp basic" { +test "Terminal: eraseDisplay erase below preserves SGR bg" { 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'); + 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(" X\n\nA", 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); + } } } -// X -test "Terminal: cursorUp below top scroll margin" { +test "Terminal: eraseDisplay below split multi-cell" { 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'); + 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("\n X\nA", str); + try testing.expectEqualStrings("AB橋C\nDE", str); } } -// X -test "Terminal: cursorUp above top scroll margin" { +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.setTopAndBottomMargin(3, 5); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(2, 1); - t.cursorUp(10); - try t.print('X'); + 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("X\n\nA", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -// X -test "Terminal: cursorUp resets wrap" { +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); - 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'); + 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("ABCDX", str); + try testing.expectEqualStrings("ABC\nD", str); } } -// X -test "Terminal: cursorRight resets wrap" { +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); - 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'); + 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("ABCDX", str); + try testing.expectEqualStrings("ABC\nD", str); } } -// X -test "Terminal: cursorRight to the edge of screen" { +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.cursorRight(100); - try t.print('X'); + 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(" X", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -// X -test "Terminal: cursorRight left of right margin" { +test "Terminal: eraseDisplay simple erase above" { 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'); + 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(" X", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -// X -test "Terminal: cursorRight right of right margin" { +test "Terminal: eraseDisplay erase above preserves SGR bg" { 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'); + 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(" X", 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); + } } } -// X -test "Terminal: scrollDown simple" { +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("ABC"); + try t.printString("AB橋C"); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + try t.printString("DE橋F"); 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); + 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("\nABC\nDEF\nGHI", str); + try testing.expectEqualStrings("\n F\nGH橋I", str); } } -// X -test "Terminal: scrollDown outside of scroll region" { +test "Terminal: eraseDisplay above protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC"); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF"); + for ("DEF") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); + for ("GHI") |c| try t.print(c); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -// X -test "Terminal: scrollDown left/right scroll region" { +test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF456"); + for ("DEF") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); + t.eraseDisplay(.above, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -// X -test "Terminal: scrollDown outside of left/right scroll region" { +test "Terminal: eraseDisplay above protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC123"); + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); t.carriageReturn(); try t.linefeed(); - try t.printString("DEF456"); + for ("DEF") |c| try t.print(c); 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); + 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("A 23\nDBC156\nGEF489\n HI7", str); + try testing.expectEqualStrings("\n F\nGHI", str); } } -// X -test "Terminal: scrollDown preserves pending wrap" { +test "Terminal: eraseDisplay above protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + 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.scrollDown(1); - try t.print('X'); + 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("\n A\n B\nX C", str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -// X -test "Terminal: scrollUp simple" { +test "Terminal: eraseDisplay protected complete" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); + try t.print('A'); 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); + 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("DEF\nGHI", str); + try testing.expectEqualStrings("\n X", str); } } -// X -test "Terminal: scrollUp top/bottom scroll region" { +test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); + try t.print('A'); t.carriageReturn(); try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - try t.scrollUp(1); + 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("ABC\nGHI", str); + try testing.expectEqualStrings("A\n123 X", str); } } -// X -test "Terminal: scrollUp left/right scroll region" { +test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, 10, 5); defer t.deinit(alloc); - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); + try t.print('A'); 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); + t.eraseDisplay(.scroll_complete, false); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + try testing.expectEqualStrings("", str); } } -// X -test "Terminal: scrollUp preserves pending wrap" { +test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 10, 3); 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); + 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(" B\n C\n\nX", str); + try testing.expectEqualStrings("\n X 9", str); } } -// X -test "Terminal: scrollUp full top/bottom region" { +test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); defer t.deinit(alloc); - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.setTopAndBottomMargin(2, 5); - try t.scrollUp(4); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top", str); - } + // 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: scrollUp full top/bottomleft/right scroll region" { +test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 3, 2); 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); + 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 str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top\n\n\n\nA E", str); + 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); } } -// X -test "Terminal: tabClear single" { - const alloc = testing.allocator; - var t = try init(alloc, 30, 5); - defer t.deinit(alloc); +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); - try t.horizontalTab(); - t.tabClear(.current); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + 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)); } -// X -test "Terminal: tabClear all" { +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, 30, 5); + var t = try init(alloc, 3, 3); defer t.deinit(alloc); - t.tabClear(.all); - t.setCursorPos(1, 1); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); + try t.print('x'); + try t.print('😀'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('😀'); // 0x1F600 } -// X -test "Terminal: printRepeat simple" { +// 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; - var t = try init(alloc, 5, 5); + const cols = 70; + const rows = 23; + var t = try init(alloc, cols, rows); defer t.deinit(alloc); - try t.printString("A"); - try t.printRepeat(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AA", str); - } + 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 -test "Terminal: printRepeat wrap" { +// https://github.com/mitchellh/ghostty/issues/1343 +test "Terminal: resize with wraparound off" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); defer t.deinit(alloc); - try t.printString(" A"); - try t.printRepeat(1); + 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(" A\nA", str); - } + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01", str); } -// X -test "Terminal: printRepeat no previous character" { +test "Terminal: resize with wraparound on" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); defer t.deinit(alloc); - try t.printRepeat(1); + 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("", str); - } + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); } -// X test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7486,7 +7709,6 @@ test "Terminal: DECCOLM without DEC mode 40" { try testing.expect(!t.modes.get(.@"132_column")); } -// X test "Terminal: DECCOLM unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7498,7 +7720,6 @@ test "Terminal: DECCOLM unset" { 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); @@ -7514,27 +7735,30 @@ test "Terminal: DECCOLM resets pending wrap" { 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; + 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 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: DECCOLM resets scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7553,80 +7777,3 @@ test "Terminal: DECCOLM resets scroll region" { 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); - defer t.deinit(alloc); - - var storage: [64]u8 = undefined; - - { - try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;38:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.faint); - try t.setAttribute(.italic); - try t.setAttribute(.{ .underline = .single }); - try t.setAttribute(.blink); - try t.setAttribute(.inverse); - try t.setAttribute(.invisible); - try t.setAttribute(.strikethrough); - try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); - } - - { - try t.setAttribute(.{ .underline = .single }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;4", buf); - } - - { - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0", buf); - } -} - -test "Terminal: preserve grapheme cluster on large scrollback" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - // 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.screen.testString(alloc, .viewport); - defer testing.allocator.free(str); - try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); - } -} 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 067217eaa1..4f3e3e48fe 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 6e4efc55be..bde44074b0 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 51261d8d47..25a97cb2e0 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 8c694f992c..4f1d7836b8 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; + +/// 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, +}; -// 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. +/// 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, -/// Active is a point within the active part of the screen. -pub const Active = struct { - x: usize = 0, - y: usize = 0, + pub const Coordinate = 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, + 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 694d5dfc0e..0000000000 --- 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 a404cf0e52..0000000000 --- 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 4f1d7836b8..0000000000 --- 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 3d1277a8a1..490d4cd5dc 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); }, From 33e59707e286364fc04a754c34c148cb505a9788 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 17:00:54 -0800 Subject: [PATCH 232/428] terminal: Screen can hold selection --- src/terminal/Screen.zig | 54 +++++++++++++++++++++++++++++++++++--- src/terminal/Selection.zig | 15 ++++++----- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 694d5dfc0e..b4d93cd909 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -37,9 +37,11 @@ cursor: Cursor, /// The saved cursor saved_cursor: ?SavedCursor = null, -/// The selection for this screen (if any). -//selection: ?Selection = null, -selection: ?void = null, +/// The selection for this screen (if any). This MUST be a tracked selection +/// otherwise the selection will become invalid. Instead of accessing this +/// directly to set it, use the `select` function which will assert and +/// automatically setup tracking. +selection: ?Selection = null, /// The charset state charset: CharsetState = .{}, @@ -827,6 +829,32 @@ pub fn manualStyleUpdate(self: *Screen) !void { self.cursor.style_ref = &md.ref; } +/// Set the selection to the given selection. If this is a tracked selection +/// then the screen will take overnship of the selection. If this is untracked +/// then the screen will convert it to tracked internally. This will automatically +/// untrack the prior selection (if any). +/// +/// Set the selection to null to clear any previous selection. +/// +/// This is always recommended over setting `selection` directly. Beyond +/// managing memory for you, it also performs safety checks that the selection +/// is always tracked. +pub fn select(self: *Screen, sel_: ?Selection) !void { + const sel = sel_ orelse { + if (self.selection) |*old| old.deinit(self); + self.selection = null; + return; + }; + + // If this selection is untracked then we track it. + const tracked_sel = if (sel.tracked()) sel else try sel.track(self); + errdefer if (!sel.tracked()) tracked_sel.deinit(self); + + // Untrack prior selection + if (self.selection) |*old| old.deinit(self); + self.selection = tracked_sel; +} + /// 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). @@ -4079,6 +4107,26 @@ test "Screen: resize more cols requiring a wide spacer head" { } } +test "Screen: select untracked" { + 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"); + + try testing.expect(s.selection == null); + const tracked = s.pages.countTrackedPins(); + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); + try s.select(null); + try testing.expectEqual(tracked, s.pages.countTrackedPins()); +} + test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index a404cf0e52..6ecc1f4a24 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -125,8 +125,8 @@ pub fn tracked(self: *const Selection) bool { } /// Convert this selection a tracked selection. It is asserted this is -/// an untracked selection. -pub fn track(self: *Selection, s: *Screen) !void { +/// an untracked selection. The tracked selection is returned. +pub fn track(self: *const Selection, s: *Screen) !Selection { assert(!self.tracked()); // Track our pins @@ -137,10 +137,13 @@ pub fn track(self: *Selection, s: *Screen) !void { const tracked_end = try s.pages.trackPin(end_pin); errdefer s.pages.untrackPin(tracked_end); - self.bounds = .{ .tracked = .{ - .start = tracked_start, - .end = tracked_end, - } }; + return .{ + .bounds = .{ .tracked = .{ + .start = tracked_start, + .end = tracked_end, + } }, + .rectangle = self.rectangle, + }; } /// Returns the top left point of the selection. From d5236bc724efac1e8022c959634a953fc648d8c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 17:32:59 -0800 Subject: [PATCH 233/428] terminal: more selection tests --- src/terminal-old/Screen.zig | 1 + src/terminal/Screen.zig | 79 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/terminal-old/Screen.zig b/src/terminal-old/Screen.zig index 385ce1eba1..5d29ef70a3 100644 --- a/src/terminal-old/Screen.zig +++ b/src/terminal-old/Screen.zig @@ -4057,6 +4057,7 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { } } +// X test "Screen: scrolling moves selection" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b4d93cd909..ee78ad8125 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2125,6 +2125,85 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { } } +test "Screen: scrolling moves selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Scroll down, should still be bottom + try s.cursorDownScroll(); + + // Our selection should've moved up + { + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom does nothing + s.scroll(.{ .active = {} }); + + // Our selection should've stayed the same + { + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scroll up again + try s.cursorDownScroll(); + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL", contents); + } + + // Our selection should be null because it left the screen. + { + const sel = s.selection.?; + try testing.expect(s.pages.pointFromPin(.active, sel.start()) == null); + try testing.expect(s.pages.pointFromPin(.active, sel.end()) == null); + } +} + test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; From 368714539edc9b41cc68a9523b461ad1466244c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 20:38:38 -0800 Subject: [PATCH 234/428] terminal-old: note test we skipped --- src/terminal-old/Screen.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal-old/Screen.zig b/src/terminal-old/Screen.zig index 5d29ef70a3..23da1728c2 100644 --- a/src/terminal-old/Screen.zig +++ b/src/terminal-old/Screen.zig @@ -4113,6 +4113,7 @@ test "Screen: scrolling moves selection" { try testing.expect(s.selection == null); } +// X - I don't think this is right test "Screen: scrolling with scrollback available doesn't move selection" { const testing = std.testing; const alloc = testing.allocator; From 25d84d697aa981df77fcb5f264a46e5cbcfdbca0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 20:47:16 -0800 Subject: [PATCH 235/428] termio/exec: get compiler errors gone --- src/renderer/size.zig | 3 +- src/terminal/kitty/graphics_storage.zig | 9 ++- src/terminal/main.zig | 1 + src/termio/Exec.zig | 75 +++++++++++++++---------- src/termio/Thread.zig | 4 +- 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/src/renderer/size.zig b/src/renderer/size.zig index d3047bb869..4f6b5fc5b3 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const font = @import("../font/main.zig"); +const terminal = @import("../terminal/main.zig"); const log = std.log.scoped(.renderer_size); @@ -61,7 +62,7 @@ pub const ScreenSize = struct { /// The dimensions of the grid itself, in rows/columns units. pub const GridSize = struct { - const Unit = u32; + const Unit = terminal.size.CellCountInt; columns: Unit = 0, rows: Unit = 0, diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index bde44074b0..d84a0a1223 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -77,10 +77,15 @@ pub const ImageStorage = struct { /// can be loaded. If this limit is lower, this will do an eviction /// if necessary. If the value is zero, then Kitty image protocol will /// be disabled. - pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void { + pub fn setLimit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + limit: usize, + ) !void { // Special case disabling by quickly deleting all if (limit == 0) { - self.deinit(alloc); + self.deinit(alloc, s); self.* = .{}; } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 25a97cb2e0..1d96871749 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -17,6 +17,7 @@ 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 size = @import("size.zig"); pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 490d4cd5dc..3e0ff071b9 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -145,8 +145,16 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { } // Set the image size limits - try term.screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit); - try term.secondary_screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit); + try term.screen.kitty_images.setLimit( + alloc, + &term.screen, + opts.config.image_storage_limit, + ); + try term.secondary_screen.kitty_images.setLimit( + alloc, + &term.secondary_screen, + opts.config.image_storage_limit, + ); // Set default cursor blink settings term.modes.set( @@ -395,10 +403,12 @@ pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void { // Set the image size limits try self.terminal.screen.kitty_images.setLimit( self.alloc, + &self.terminal.screen, config.image_storage_limit, ); try self.terminal.secondary_screen.kitty_images.setLimit( self.alloc, + &self.terminal.secondary_screen, config.image_storage_limit, ); } @@ -464,11 +474,15 @@ pub fn clearScreen(self: *Exec, history: bool) !void { if (self.terminal.active_screen == .alternate) return; // Clear our scrollback - if (history) self.terminal.eraseDisplay(self.alloc, .scrollback, false); + if (history) self.terminal.eraseDisplay(.scrollback, false); // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { - try self.terminal.screen.clear(.above_cursor); + self.terminal.screen.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + false, + ); return; } @@ -478,7 +492,7 @@ pub fn clearScreen(self: *Exec, history: bool) !void { // clear the full screen in the next eraseDisplay call. self.terminal.markSemanticPrompt(.command); assert(!self.terminal.cursorIsAtPrompt()); - self.terminal.eraseDisplay(self.alloc, .complete, false); + self.terminal.eraseDisplay(.complete, false); } // If we reached here it means we're at a prompt, so we send a form-feed. @@ -494,17 +508,20 @@ pub fn scrollViewport(self: *Exec, scroll: terminal.Terminal.ScrollViewport) !vo /// Jump the viewport to the prompt. pub fn jumpToPrompt(self: *Exec, delta: isize) !void { - const wakeup: bool = wakeup: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - break :wakeup self.terminal.screen.jump(.{ - .prompt_delta = delta, - }); - }; - - if (wakeup) { - try self.renderer_wakeup.notify(); - } + _ = self; + _ = delta; + // TODO(paged-terminal) + // const wakeup: bool = wakeup: { + // self.renderer_state.mutex.lock(); + // defer self.renderer_state.mutex.unlock(); + // break :wakeup self.terminal.screen.jump(.{ + // .prompt_delta = delta, + // }); + // }; + // + // if (wakeup) { + // try self.renderer_wakeup.notify(); + // } } /// Called when the child process exited abnormally but before @@ -2007,7 +2024,7 @@ const StreamHandler = struct { try self.queueRender(); } - self.terminal.eraseDisplay(self.alloc, mode, protected); + self.terminal.eraseDisplay(mode, protected); } pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { @@ -2015,7 +2032,7 @@ const StreamHandler = struct { } pub fn deleteChars(self: *StreamHandler, count: usize) !void { - try self.terminal.deleteChars(count); + self.terminal.deleteChars(count); } pub fn eraseChars(self: *StreamHandler, count: usize) !void { @@ -2023,7 +2040,7 @@ const StreamHandler = struct { } pub fn insertLines(self: *StreamHandler, count: usize) !void { - try self.terminal.insertLines(count); + self.terminal.insertLines(count); } pub fn insertBlanks(self: *StreamHandler, count: usize) !void { @@ -2031,11 +2048,11 @@ const StreamHandler = struct { } pub fn deleteLines(self: *StreamHandler, count: usize) !void { - try self.terminal.deleteLines(count); + self.terminal.deleteLines(count); } pub fn reverseIndex(self: *StreamHandler) !void { - try self.terminal.reverseIndex(); + self.terminal.reverseIndex(); } pub fn index(self: *StreamHandler) !void { @@ -2183,9 +2200,9 @@ const StreamHandler = struct { }; if (enabled) - self.terminal.alternateScreen(self.alloc, opts) + self.terminal.alternateScreen(opts) else - self.terminal.primaryScreen(self.alloc, opts); + self.terminal.primaryScreen(opts); // Schedule a render since we changed screens try self.queueRender(); @@ -2198,9 +2215,9 @@ const StreamHandler = struct { }; if (enabled) - self.terminal.alternateScreen(self.alloc, opts) + self.terminal.alternateScreen(opts) else - self.terminal.primaryScreen(self.alloc, opts); + self.terminal.primaryScreen(opts); // Schedule a render since we changed screens try self.queueRender(); @@ -2424,7 +2441,7 @@ const StreamHandler = struct { } pub fn restoreCursor(self: *StreamHandler) !void { - self.terminal.restoreCursor(); + try self.terminal.restoreCursor(); } pub fn enquiry(self: *StreamHandler) !void { @@ -2433,11 +2450,11 @@ const StreamHandler = struct { } pub fn scrollDown(self: *StreamHandler, count: usize) !void { - try self.terminal.scrollDown(count); + self.terminal.scrollDown(count); } pub fn scrollUp(self: *StreamHandler, count: usize) !void { - try self.terminal.scrollUp(count); + self.terminal.scrollUp(count); } pub fn setActiveStatusDisplay( @@ -2467,7 +2484,7 @@ const StreamHandler = struct { pub fn fullReset( self: *StreamHandler, ) !void { - self.terminal.fullReset(self.alloc); + self.terminal.fullReset(); try self.setMouseShape(.text); } diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 450f322957..45b9fd1ae4 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -177,7 +177,7 @@ pub fn threadMain(self: *Thread) void { \\Please free up some pty devices and try again. ; - t.eraseDisplay(alloc, .complete, false); + t.eraseDisplay(.complete, false); t.printString(str) catch {}; }, @@ -197,7 +197,7 @@ pub fn threadMain(self: *Thread) void { \\Out of memory. This terminal is non-functional. Please close it and try again. ; - t.eraseDisplay(alloc, .complete, false); + t.eraseDisplay(.complete, false); t.printString(str) catch {}; }, } From 4c4d5f5a890a9ba34883b665976d7107278048bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 20:52:14 -0800 Subject: [PATCH 236/428] terminal/kitty: graphics exec ported --- src/terminal/kitty/graphics_exec.zig | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index b4047c1d5e..65c7f5bf78 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -184,15 +184,19 @@ fn display( // Make sure our response has the image id in case we looked up by number result.id = img.id; - // Determine the screen point for the placement. - const placement_point = (point.Viewport{ - .x = terminal.screen.cursor.x, - .y = terminal.screen.cursor.y, - }).toScreen(&terminal.screen); + // Track a new pin for our cursor. The cursor is always tracked but we + // don't want this one to move with the cursor. + const placement_pin = terminal.screen.pages.trackPin( + terminal.screen.cursor.page_pin.*, + ) catch |err| { + log.warn("failed to create pin for Kitty graphics err={}", .{err}); + result.message = "EINVAL: failed to prepare terminal state"; + return result; + }; // Add the placement const p: ImageStorage.Placement = .{ - .point = placement_point, + .pin = placement_pin, .x_offset = d.x_offset, .y_offset = d.y_offset, .source_x = d.x, @@ -209,6 +213,7 @@ fn display( result.placement_id, p, ) catch |err| { + p.deinit(&terminal.screen); encodeError(&result, err); return result; }; From d966e74f455ed2efbd9c915bcc60cfa9720fe4ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 21:00:15 -0800 Subject: [PATCH 237/428] core: surface compiles --- src/Surface.zig | 138 +++++++++++++++++++++-------------------- src/terminal/point.zig | 7 +-- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 815109dbc3..b0cc4b7dbc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1359,41 +1359,39 @@ pub fn keyCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); var screen = self.io.terminal.screen; - const sel = sel: { - const old_sel = screen.selection orelse break :adjust_selection; - break :sel old_sel.adjust(&screen, switch (event.key) { - .left => .left, - .right => .right, - .up => .up, - .down => .down, - .page_up => .page_up, - .page_down => .page_down, - .home => .home, - .end => .end, - else => break :adjust_selection, - }); - }; + const sel = if (screen.selection) |*sel| sel else break :adjust_selection; + sel.adjust(&screen, switch (event.key) { + .left => .left, + .right => .right, + .up => .up, + .down => .down, + .page_up => .page_up, + .page_down => .page_down, + .home => .home, + .end => .end, + else => break :adjust_selection, + }); // Silently consume key releases. if (event.action != .press and event.action != .repeat) return .consumed; // If the selection endpoint is outside of the current viewpoint, // scroll it in to view. - scroll: { - const viewport_max = terminal.Screen.RowIndexTag.viewport.maxLen(&screen) - 1; - const viewport_end = screen.viewport + viewport_max; - const delta: isize = if (sel.end.y < screen.viewport) - @intCast(screen.viewport) - else if (sel.end.y > viewport_end) - @intCast(viewport_end) - else - break :scroll; - const start_y: isize = @intCast(sel.end.y); - try self.io.terminal.scrollViewport(.{ .delta = start_y - delta }); - } - - // Change our selection and queue a render so its shown. - self.setSelection(sel); + // TODO(paged-terminal) + // scroll: { + // const viewport_max = terminal.Screen.RowIndexTag.viewport.maxLen(&screen) - 1; + // const viewport_end = screen.viewport + viewport_max; + // const delta: isize = if (sel.end.y < screen.viewport) + // @intCast(screen.viewport) + // else if (sel.end.y > viewport_end) + // @intCast(viewport_end) + // else + // break :scroll; + // const start_y: isize = @intCast(sel.end.y); + // try self.io.terminal.scrollViewport(.{ .delta = start_y - delta }); + // } + + // Queue a render so its shown try self.queueRender(); return .consumed; } @@ -2139,17 +2137,20 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - const cell = self.renderer_state.terminal.screen.getCell( - .viewport, - point.y, - point.x, - ); - - insp.cell = .{ .selected = .{ - .row = point.y, - .col = point.x, - .cell = cell, - } }; + // TODO(paged-terminal) + // const cell = self.renderer_state.terminal.screen.getCell( + // .viewport, + // point.y, + // point.x, + // ); + + insp.cell = .{ + .selected = .{ + .row = point.y, + .col = point.x, + //.cell = cell, + }, + }; return; } } @@ -2250,15 +2251,19 @@ pub fn mouseButtonCallback( // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - try self.clickMoveCursor(self.mouse.left_click_point); + // TODO(paged-terminal) + // self.renderer_state.mutex.lock(); + // defer self.renderer_state.mutex.unlock(); + // try self.clickMoveCursor(self.mouse.left_click_point); return; } // For left button clicks we always record some information for // selection/highlighting purposes. - if (button == .left and action == .press) { + if (button == .left and action == .press) click: { + // TODO(paged-terminal) + if (true) break :click; + self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); @@ -2429,6 +2434,9 @@ fn linkAtPos( DerivedConfig.Link, terminal.Selection, } { + // TODO(paged-terminal) + if (true) return null; + // If we have no configured links we can save a lot of work if (self.config.links.len == 0) return null; @@ -2536,7 +2544,8 @@ pub fn cursorPosCallback( if (self.inspector) |insp| { insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - insp.mouse.last_point = pos_vp.toScreen(&self.io.terminal.screen); + // TODO(paged-terminal) + //insp.mouse.last_point = pos_vp.toScreen(&self.io.terminal.screen); try self.queueRender(); } @@ -2590,16 +2599,17 @@ pub fn cursorPosCallback( } // Convert to points - const screen_point = pos_vp.toScreen(&self.io.terminal.screen); - - // Handle dragging depending on click count - switch (self.mouse.left_click_count) { - 1 => self.dragLeftClickSingle(screen_point, pos.x), - 2 => self.dragLeftClickDouble(screen_point), - 3 => self.dragLeftClickTriple(screen_point), - 0 => unreachable, // handled above - else => unreachable, - } + // TODO(paged-terminal) + // const screen_point = pos_vp.toScreen(&self.io.terminal.screen); + // + // // Handle dragging depending on click count + // switch (self.mouse.left_click_count) { + // 1 => self.dragLeftClickSingle(screen_point, pos.x), + // 2 => self.dragLeftClickDouble(screen_point), + // 3 => self.dragLeftClickTriple(screen_point), + // 0 => unreachable, // handled above + // else => unreachable, + // } return; } @@ -3035,7 +3045,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .reset => { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.renderer_state.terminal.fullReset(self.alloc); + self.renderer_state.terminal.fullReset(); }, .copy_to_clipboard => { @@ -3185,20 +3195,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool break :write_scrollback_file; } - const history_max = terminal.Screen.RowIndexTag.history.maxLen( - &self.io.terminal.screen, - ); - // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this // command always works on the primary screen. - if (history_max > 0) { - try self.io.terminal.screen.dumpString(file.writer(), .{ - .start = .{ .history = 0 }, - .end = .{ .history = history_max -| 1 }, - .unwrap = true, - }); - } + // TODO(paged-terminal): unwrap + try self.io.terminal.screen.dumpString( + file.writer(), + .{ .history = .{} }, + ); } // Get the final path diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 4f1d7836b8..19ab367771 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -70,6 +70,7 @@ pub const Point = union(Tag) { }; /// A point in the terminal that is always in the viewport area. +/// TODO(paged-terminal): remove pub const Viewport = struct { x: usize = 0, y: usize = 0, @@ -78,9 +79,3 @@ pub const Viewport = struct { 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, -}; From c61de49082cfcd260f9ee7b47cf15c9767bdea10 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 21:33:10 -0800 Subject: [PATCH 238/428] renderer/metal: port --- src/renderer/Metal.zig | 219 ++++++++++++++++++---------------------- src/renderer/cell.zig | 44 ++++---- src/terminal/Screen.zig | 1 + src/terminal/page.zig | 8 ++ src/terminal/style.zig | 50 +++++++++ 5 files changed, 182 insertions(+), 140 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 02ba2f93f7..f9393fc461 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -623,7 +623,6 @@ pub fn updateFrame( // Data we extract out of the critical area. const Critical = struct { bg: terminal.color.RGB, - selection: ?terminal.Selection, screen: terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, @@ -657,25 +656,13 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to // hold the lock while rebuilding GPU cells. - const viewport_bottom = state.terminal.screen.viewportIsBottom(); - var screen_copy = if (viewport_bottom) try state.terminal.screen.clone( + var screen_copy = try state.terminal.screen.clone( self.alloc, - .{ .active = 0 }, - .{ .active = state.terminal.rows - 1 }, - ) else try state.terminal.screen.clone( - self.alloc, - .{ .viewport = 0 }, - .{ .viewport = state.terminal.rows - 1 }, + .{ .viewport = .{} }, + null, ); errdefer screen_copy.deinit(); - // Convert our selection to viewport points because we copy only - // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| - sel.toViewport(&state.terminal.screen) - else - null; - // Whether to draw our cursor or not. const cursor_style = renderer.cursorStyle( state, @@ -694,13 +681,15 @@ pub fn updateFrame( // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if // it changes. - if (state.terminal.screen.kitty_images.dirty) { - try self.prepKittyGraphics(state.terminal); + // TODO(paged-terminal) + if (false) { + if (state.terminal.screen.kitty_images.dirty) { + try self.prepKittyGraphics(state.terminal); + } } break :critical .{ .bg = self.background_color, - .selection = selection, .screen = screen_copy, .mouse = state.mouse, .preedit = preedit, @@ -715,7 +704,6 @@ pub fn updateFrame( // Build our GPU cells try self.rebuildCells( - critical.selection, &critical.screen, critical.mouse, critical.preedit, @@ -1536,16 +1524,21 @@ pub fn setScreenSize( /// down to the GPU yet. fn rebuildCells( self: *Metal, - term_selection: ?terminal.Selection, screen: *terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, ) !void { + const rows_usize: usize = @intCast(screen.pages.rows); + const cols_usize: usize = @intCast(screen.pages.cols); + // Bg cells at most will need space for the visible screen size self.cells_bg.clearRetainingCapacity(); - try self.cells_bg.ensureTotalCapacity(self.alloc, screen.rows * screen.cols); + try self.cells_bg.ensureTotalCapacity( + self.alloc, + rows_usize * cols_usize, + ); // Over-allocate just to ensure we don't allocate again during loops. self.cells.clearRetainingCapacity(); @@ -1554,21 +1547,24 @@ fn rebuildCells( // * 3 for glyph + underline + strikethrough for each cell // + 1 for cursor - (screen.rows * screen.cols * 3) + 1, + (rows_usize * cols_usize * 3) + 1, ); // Create an arena for all our temporary allocations while rebuilding var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); const arena_alloc = arena.allocator(); + _ = arena_alloc; + _ = mouse; // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; + // TODO(paged-terminal) + // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + // arena_alloc, + // screen, + // mouse_pt, + // mouse.mods, + // ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -1577,7 +1573,7 @@ fn rebuildCells( x: [2]usize, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.cols - 1); + const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); break :preedit .{ .y = screen.cursor.y, .x = .{ range.start, range.end }, @@ -1591,9 +1587,9 @@ fn rebuildCells( var cursor_cell: ?mtl_shaders.Cell = null; // Build each cell - var rowIter = screen.rowIterator(.viewport); + var row_it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var y: usize = 0; - while (rowIter.next()) |row| { + while (row_it.next()) |row| { defer y += 1; // True if this is the row with our cursor. There are a lot of conditions @@ -1628,8 +1624,8 @@ fn rebuildCells( defer if (cursor_row) { // If we're on a wide spacer tail, then we want to look for // the previous cell. - const screen_cell = row.getCell(screen.cursor.x); - const x = screen.cursor.x - @intFromBool(screen_cell.attrs.wide_spacer_tail); + const screen_cell = row.cells(.all)[screen.cursor.x]; + const x = screen.cursor.x - @intFromBool(screen_cell.wide == .spacer_tail); for (self.cells.items[start_i..]) |cell| { if (cell.grid_pos[0] == @as(f32, @floatFromInt(x)) and (cell.mode == .fg or cell.mode == .fg_color)) @@ -1643,15 +1639,16 @@ fn rebuildCells( // We need to get this row's selection if there is one for proper // run splitting. const row_selection = sel: { - if (term_selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = 0, - .y = y, - }).toScreen(screen); - if (sel.containedRow(screen, screen_point)) |row_sel| { - break :sel row_sel; - } - } + // TODO(paged-terminal) + // if (screen.selection) |sel| { + // const screen_point = (terminal.point.Viewport{ + // .x = 0, + // .y = y, + // }).toScreen(screen); + // if (sel.containedRow(screen, screen_point)) |row_sel| { + // break :sel row_sel; + // } + // } break :sel null; }; @@ -1659,6 +1656,7 @@ fn rebuildCells( // Split our row into runs and shape each one. var iter = self.font_shaper.runIterator( self.font_group, + screen, row, row_selection, if (shape_cursor) screen.cursor.x else null, @@ -1679,24 +1677,23 @@ fn rebuildCells( // It this cell is within our hint range then we need to // underline it. - const cell: terminal.Screen.Cell = cell: { - var cell = row.getCell(shaper_cell.x); - - // If our links contain this cell then we want to - // underline it. - if (link_match_set.orderedContains(.{ - .x = shaper_cell.x, - .y = y, - })) { - cell.attrs.underline = .single; - } - - break :cell cell; + const cell: terminal.Pin = cell: { + var copy = row; + copy.x = shaper_cell.x; + break :cell copy; + + // TODO(paged-terminal) + // // If our links contain this cell then we want to + // // underline it. + // if (link_match_set.orderedContains(.{ + // .x = shaper_cell.x, + // .y = y, + // })) { + // cell.attrs.underline = .single; + // } }; if (self.updateCell( - term_selection, - screen, cell, color_palette, shaper_cell, @@ -1714,9 +1711,6 @@ fn rebuildCells( } } } - - // Set row is not dirty anymore - row.setDirty(false); } // Add the cursor at the end so that it overlays everything. If we have @@ -1744,7 +1738,8 @@ fn rebuildCells( break :cursor_style; } - _ = self.addCursor(screen, cursor_style); + _ = cursor_style; + //_ = self.addCursor(screen, cursor_style); if (cursor_cell) |*cell| { if (cell.mode == .fg) { cell.color = if (self.config.cursor_text) |txt| @@ -1766,9 +1761,7 @@ fn rebuildCells( fn updateCell( self: *Metal, - selection: ?terminal.Selection, - screen: *terminal.Screen, - cell: terminal.Screen.Cell, + cell_pin: terminal.Pin, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1790,45 +1783,35 @@ fn updateCell( // True if this cell is selected // TODO(perf): we can check in advance if selection is in // our viewport at all and not run this on every point. - const selected: bool = if (selection) |sel| selected: { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); + const selected = false; + // TODO(paged-terminal) + // const selected: bool = if (screen.selection) |sel| selected: { + // const screen_point = (terminal.point.Viewport{ + // .x = x, + // .y = y, + // }).toScreen(screen); + // + // break :selected sel.contains(screen_point); + // } else false; - break :selected sel.contains(screen_point); - } else false; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + const style = cell_pin.style(cell); // The colors for the cell. const colors: BgFg = colors: { // The normal cell result - const cell_res: BgFg = if (!cell.attrs.inverse) .{ + const cell_res: BgFg = if (!style.flags.inverse) .{ // In normal mode, background and fg match the cell. We // un-optionalize the fg by defaulting to our fg color. - .bg = switch (cell.bg) { - .none => null, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.bg(cell, palette), + .fg = style.fg(palette) orelse self.foreground_color, } else .{ // In inverted mode, the background MUST be set to something // (is never null) so it is either the fg or default fg. The // fg is either the bg or default background. - .bg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.bg) { - .none => self.background_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.fg(palette) orelse self.foreground_color, + .fg = style.bg(cell, palette) orelse self.background_color, }; // If we are selected, we our colors are just inverted fg/bg @@ -1846,7 +1829,7 @@ fn updateCell( // If the cell is "invisible" then we just make fg = bg so that // the cell is transparent but still copy-able. const res: BgFg = selection_res orelse cell_res; - if (cell.attrs.invisible) { + if (style.flags.invisible) { break :colors BgFg{ .bg = res.bg, .fg = res.bg orelse self.background_color, @@ -1857,7 +1840,7 @@ fn updateCell( }; // Alpha multiplier - const alpha: u8 = if (cell.attrs.faint) 175 else 255; + const alpha: u8 = if (style.flags.faint) 175 else 255; // If the cell has a background, we always draw it. const bg: [4]u8 = if (colors.bg) |rgb| bg: { @@ -1874,11 +1857,11 @@ fn updateCell( if (selected) break :bg_alpha default; // If we're reversed, do not apply background opacity - if (cell.attrs.inverse) break :bg_alpha default; + if (style.flags.inverse) break :bg_alpha default; // If we have a background and its not the default background // then we apply background opacity - if (cell.bg != .none and !rgb.eql(self.background_color)) { + if (style.bg(cell, palette) != null and !rgb.eql(self.background_color)) { break :bg_alpha default; } @@ -1892,7 +1875,7 @@ fn updateCell( self.cells_bg.appendAssumeCapacity(.{ .mode = .bg, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ rgb.r, rgb.g, rgb.b, bg_alpha }, .bg_color = .{ 0, 0, 0, 0 }, }); @@ -1906,7 +1889,7 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) fg: { + if (cell.hasText()) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, @@ -1920,11 +1903,8 @@ fn updateCell( const mode: mtl_shaders.Cell.Mode = switch (try fgMode( &self.font_group.group, - screen, - cell, + cell_pin, shaper_run, - x, - y, )) { .normal => .fg, .color => .fg_color, @@ -1934,7 +1914,7 @@ fn updateCell( self.cells.appendAssumeCapacity(.{ .mode = mode, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, @@ -1946,8 +1926,8 @@ fn updateCell( }); } - if (cell.attrs.underline != .none) { - const sprite: font.Sprite = switch (cell.attrs.underline) { + if (style.flags.underline != .none) { + const sprite: font.Sprite = switch (style.flags.underline) { .none => unreachable, .single => .underline, .double => .underline_double, @@ -1961,17 +1941,17 @@ fn updateCell( font.sprite_index, @intFromEnum(sprite), .{ - .cell_width = if (cell.attrs.wide) 2 else 1, + .cell_width = if (cell.wide == .wide) 2 else 1, .grid_metrics = self.grid_metrics, }, ); - const color = if (cell.attrs.underline_color) cell.underline_fg else colors.fg; + const color = style.underlineColor(palette) orelse colors.fg; self.cells.appendAssumeCapacity(.{ .mode = .fg, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ color.r, color.g, color.b, alpha }, .bg_color = bg, .glyph_pos = .{ glyph.atlas_x, glyph.atlas_y }, @@ -1980,11 +1960,11 @@ fn updateCell( }); } - if (cell.attrs.strikethrough) { + if (style.flags.strikethrough) { self.cells.appendAssumeCapacity(.{ .mode = .strikethrough, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, - .cell_width = cell.widthLegacy(), + .cell_width = cell.gridWidth(), .color = .{ colors.fg.r, colors.fg.g, colors.fg.b, alpha }, .bg_color = bg, }); @@ -2002,21 +1982,14 @@ fn addCursor( // we're on the wide characer tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x, - ); - if (!cell.attrs.wide_spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.attrs.wide, screen.cursor.x }; + const cell = screen.cursor.page_cell; + if (cell.wide != .spacer_tail or screen.cursor.x == 0) + break :cell .{ cell.wide == .wide, screen.cursor.x }; // If we're part of a wide character, we move the cursor back to // the actual character. - break :cell .{ screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x - 1, - ).attrs.wide, screen.cursor.x - 1 }; + const prev_cell = screen.cursorCellLeft(1); + break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; }; const color = self.cursor_color orelse self.foreground_color; diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 1a5ac51d9d..44087da444 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -21,11 +21,8 @@ pub const FgMode = enum { /// renderer. pub fn fgMode( group: *font.Group, - screen: *terminal.Screen, - cell: terminal.Screen.Cell, + cell_pin: terminal.Pin, shaper_run: font.shape.TextRun, - x: usize, - y: usize, ) !FgMode { const presentation = try group.presentationFromIndex(shaper_run.font_index); return switch (presentation) { @@ -41,42 +38,55 @@ pub fn fgMode( // the subsequent character is empty, then we allow it to use // the full glyph size. See #1071. .text => text: { - if (!ziglyph.general_category.isPrivateUse(@intCast(cell.char)) and - !ziglyph.blocks.isDingbats(@intCast(cell.char))) + const cell = cell_pin.rowAndCell().cell; + const cp = cell.codepoint(); + + if (!ziglyph.general_category.isPrivateUse(cp) and + !ziglyph.blocks.isDingbats(cp)) { break :text .normal; } // We exempt the Powerline range from this since they exhibit // box-drawing behavior and should not be constrained. - if (isPowerline(cell.char)) { + if (isPowerline(cp)) { break :text .normal; } // If we are at the end of the screen its definitely constrained - if (x == screen.cols - 1) break :text .constrained; + if (cell_pin.x == cell_pin.page.data.size.cols - 1) break :text .constrained; // If we have a previous cell and it was PUA then we need to // also constrain. This is so that multiple PUA glyphs align. // As an exception, we ignore powerline glyphs since they are // used for box drawing and we consider them whitespace. - if (x > 0) prev: { - const prev_cell = screen.getCell(.active, y, x - 1); + if (cell_pin.x > 0) prev: { + const prev_cp = prev_cp: { + var copy = cell_pin; + copy.x -= 1; + const prev_cell = copy.rowAndCell().cell; + break :prev_cp prev_cell.codepoint(); + }; // Powerline is whitespace - if (isPowerline(prev_cell.char)) break :prev; + if (isPowerline(prev_cp)) break :prev; - if (ziglyph.general_category.isPrivateUse(@intCast(prev_cell.char))) { + if (ziglyph.general_category.isPrivateUse(prev_cp)) { break :text .constrained; } } // If the next cell is empty, then we allow it to use the // full glyph size. - const next_cell = screen.getCell(.active, y, x + 1); - if (next_cell.char == 0 or - next_cell.char == ' ' or - isPowerline(next_cell.char)) + const next_cp = next_cp: { + var copy = cell_pin; + copy.x += 1; + const next_cell = copy.rowAndCell().cell; + break :next_cp next_cell.codepoint(); + }; + if (next_cp == 0 or + next_cp == ' ' or + isPowerline(next_cp)) { break :text .normal; } @@ -88,7 +98,7 @@ pub fn fgMode( } // Returns true if the codepoint is a part of the Powerline range. -fn isPowerline(char: u32) bool { +fn isPowerline(char: u21) bool { return switch (char) { 0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true, else => false, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ee78ad8125..5438afb2ce 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -227,6 +227,7 @@ pub fn clonePool( .pages = pages, .no_scrollback = self.no_scrollback, + // TODO: selection // TODO: let's make this reasonble .cursor = undefined, }; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 69d2867098..0e53696a7c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -779,6 +779,14 @@ pub const Cell = packed struct(u64) { }; } + /// The width in grid cells that this cell takes up. + pub fn gridWidth(self: Cell) u2 { + return switch (self.wide) { + .narrow, .spacer_head, .spacer_tail => 1, + .wide => 2, + }; + } + pub fn hasStyling(self: Cell) bool { return self.style_id != style.default_id; } diff --git a/src/terminal/style.zig b/src/terminal/style.zig index e486307118..0d73801852 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -51,6 +51,56 @@ pub const Style = struct { return std.mem.eql(u8, std.mem.asBytes(&self), def); } + /// Returns the bg color for a cell with this style given the cell + /// that has this style and the palette to use. + /// + /// Note that generally if a cell is a color-only cell, it SHOULD + /// only have the default style, but this is meant to work with the + /// default style as well. + pub fn bg( + self: Style, + cell: *const page.Cell, + palette: *const color.Palette, + ) ?color.RGB { + return switch (cell.content_tag) { + .bg_color_palette => palette[cell.content.color_palette], + .bg_color_rgb => rgb: { + const rgb = cell.content.color_rgb; + break :rgb .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + }, + + else => switch (self.bg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }, + }; + } + + /// Returns the fg color for a cell with this style given the palette. + pub fn fg( + self: Style, + palette: *const color.Palette, + ) ?color.RGB { + return switch (self.fg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + } + + /// Returns the underline color for this style. + pub fn underlineColor( + self: Style, + palette: *const color.Palette, + ) ?color.RGB { + return switch (self.underline_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + } + /// Returns a bg-color only cell from this style, if it exists. pub fn bgCell(self: Style) ?page.Cell { return switch (self.bg_color) { From ea51e9bca5acc98424051ae6022e324756e99c78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Mar 2024 21:40:40 -0800 Subject: [PATCH 239/428] inspector: todo on render --- src/inspector/Inspector.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 11ef18a06a..4262dccb4b 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -179,6 +179,9 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { + // TODO(paged-terminal) + if (true) return; + const dock_id = cimgui.c.igDockSpaceOverViewport( cimgui.c.igGetMainViewport(), cimgui.c.ImGuiDockNodeFlags_None, From a1e8a59aa3cc1d962ba42b7f525d4cfb8b4fa679 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 13:35:45 -0800 Subject: [PATCH 240/428] terminal: correct cols/rows order --- src/terminal/Terminal.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 94d33f7343..c1e9b929cb 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2050,17 +2050,17 @@ pub fn resize( if (self.active_screen == .primary) { self.clearPromptForResize(); if (self.modes.get(.wraparound)) { - try self.screen.resize(rows, cols); + try self.screen.resize(cols, rows); } else { - try self.screen.resizeWithoutReflow(rows, cols); + try self.screen.resizeWithoutReflow(cols, rows); } - try self.secondary_screen.resizeWithoutReflow(rows, cols); + try self.secondary_screen.resizeWithoutReflow(cols, rows); } else { - try self.screen.resizeWithoutReflow(rows, cols); + try self.screen.resizeWithoutReflow(cols, rows); if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(rows, cols); + try self.secondary_screen.resize(cols, rows); } else { - try self.secondary_screen.resizeWithoutReflow(rows, cols); + try self.secondary_screen.resizeWithoutReflow(cols, rows); } } From 3154686f9e6813846f1db80dbebfa980ec3c8f54 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:02:58 -0800 Subject: [PATCH 241/428] terminal: proper cursor copy for alt screen --- src/terminal/Screen.zig | 88 +++++++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 15 +++---- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5438afb2ce..fd0436b6e2 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -437,6 +437,32 @@ fn cursorDownOrScroll(self: *Screen) !void { } } +/// Copy another cursor. The cursor can be on any screen but the x/y +/// must be within our screen bounds. +pub fn cursorCopy(self: *Screen, other: Cursor) !void { + assert(other.x < self.pages.cols); + assert(other.y < self.pages.rows); + + const old = self.cursor; + self.cursor = other; + errdefer self.cursor = old; + + // We need to keep our old x/y because that is our cursorAbsolute + // will fix up our pointers. + // + // We keep our old page pin because we expect to be in the active + // page relative to our own screen. + self.cursor.page_pin = old.page_pin; + self.cursor.x = old.x; + self.cursor.y = old.y; + self.cursorAbsolute(other.x, other.y); + + // We keep the old style ref so manualStyleUpdate can clean our old style up. + self.cursor.style_id = old.style_id; + self.cursor.style_ref = old.style_ref; + try self.manualStyleUpdate(); +} + /// 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. @@ -1730,6 +1756,68 @@ test "Screen read and write no scrollback large" { } } +test "Screen cursorCopy x/y" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); + s.cursorAbsolute(2, 3); + try testing.expect(s.cursor.x == 2); + try testing.expect(s.cursor.y == 3); + + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + try s2.cursorCopy(s.cursor); + try testing.expect(s2.cursor.x == 2); + try testing.expect(s2.cursor.y == 3); + try s2.testWriteString("Hello"); + + { + const str = try s2.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("\n\n\n Hello", str); + } +} + +test "Screen cursorCopy style deref" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); + + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + const page = s2.cursor.page_pin.page.data; + + // Bold should create our style + try s2.setAttribute(.{ .bold = {} }); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s2.cursor.style.flags.bold); + + // Copy default style, should release our style + try s2.cursorCopy(s.cursor); + try testing.expect(!s2.cursor.style.flags.bold); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Screen cursorCopy style copy" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 10, 10, 0); + defer s.deinit(); + try s.setAttribute(.{ .bold = {} }); + + var s2 = try Screen.init(alloc, 10, 10, 0); + defer s2.deinit(); + const page = s2.cursor.page_pin.page.data; + try s2.cursorCopy(s.cursor); + try testing.expect(s2.cursor.style.flags.bold); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); +} + test "Screen style basics" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c1e9b929cb..1c1f319de3 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2142,19 +2142,13 @@ pub fn alternateScreen( 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); + self.screen.cursorCopy(old.cursor) catch |err| { + log.warn("cursor copy failed entering alt screen err={}", .{err}); + }; 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}); - }; } /// Switch back to the primary screen (reset alternate screen mode). @@ -6414,7 +6408,8 @@ test "Terminal: saveCursor with screen change" { defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); - t.screen.cursor.x = 2; + t.setCursorPos(t.screen.cursor.y + 1, 3); + try testing.expect(t.screen.cursor.x == 2); t.screen.charset.gr = .G3; t.modes.set(.origin, true); t.alternateScreen(.{ From 6255ab7f204cbe605fcfd75f41d75610951fe0da Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:06:07 -0800 Subject: [PATCH 242/428] terminal: PageList resize should set styled on row if style copy --- src/terminal/PageList.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index fb4005991e..bf8cc41180 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -861,6 +861,7 @@ fn reflowPage( ); dst_md.ref += 1; dst_cursor.page_cell.style_id = dst_md.id; + dst_cursor.page_row.styled = true; } } @@ -4981,6 +4982,9 @@ test "PageList resize reflow less cols copy style" { style_id, ).?; try testing.expect(style.flags.bold); + + const row = rac.row; + try testing.expect(row.styled); } } } From 7c7e611192df1521cbe7408508c429911fff8409 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:07:20 -0800 Subject: [PATCH 243/428] terminal: test to ensure grapheme flag is set on row when resizing --- src/terminal/PageList.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index bf8cc41180..d1b210880a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4598,6 +4598,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -4619,6 +4620,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { const rac = offset.rowAndCell(); const cells = offset.page.data.getCells(rac.row); try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); From ae19a424fc7302ac64fb54da9ad324d61e1b2a17 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:39:04 -0800 Subject: [PATCH 244/428] terminal: pagelist verify erasing history resets to one page --- src/terminal/PageList.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d1b210880a..c2adce9084 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1991,6 +1991,18 @@ fn totalRows(self: *const PageList) usize { return rows; } +/// The total number of pages in this list. +fn totalPages(self: *const PageList) usize { + var pages: usize = 0; + var page = self.pages.first; + while (page) |p| { + pages += 1; + page = p.next; + } + + return pages; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized. fn growRows(self: *PageList, n: usize) !void { @@ -3054,12 +3066,14 @@ test "PageList erase" { var s = try init(alloc, 80, 24, null); defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); // Grow so we take up at least 5 pages. const page = &s.pages.last.?.data; for (0..page.capacity.rows * 5) |_| { _ = try s.grow(); } + try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large try testing.expect(s.totalRows() > s.rows); @@ -3067,6 +3081,10 @@ test "PageList erase" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); try testing.expectEqual(s.rows, s.totalRows()); + + // We should be back to just one page + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try testing.expect(s.pages.first == s.pages.last); } test "PageList erase row with tracked pin resets to top-left" { From bf79c040ce68bb86fbcf983f6c5078a4a6aa0a39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:43:20 -0800 Subject: [PATCH 245/428] terminal: erase complete deletes kitty images again --- src/terminal/Terminal.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1c1f319de3..c4c570d387 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1788,8 +1788,11 @@ pub fn eraseDisplay( self.screen.cursor.pending_wrap = false; // Clear all Kitty graphics state for this screen - // TODO - //self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + self.screen.kitty_images.delete( + self.screen.alloc, + self, + .{ .all = true }, + ); }, .below => { From 775049e1c01a10e83b83377e2c5230f0d430f171 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 14:51:53 -0800 Subject: [PATCH 246/428] terminal: PageList updates page size accounting when erasing page --- src/terminal/PageList.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c2adce9084..b6f913fa09 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1337,9 +1337,15 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { /// Destroy the memory of the given page and return it to the pool. The /// page is assumed to already be removed from the linked list. fn destroyPage(self: *PageList, page: *List.Node) void { + // Reset the memory to zero so it can be reused @memset(page.data.memory, 0); + + // Put it back into the allocator pool self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); self.pool.nodes.destroy(page); + + // Update our accounting for page size + self.page_size -= PagePool.item_size; } /// Erase the rows from the given top to bottom (inclusive). Erasing @@ -3087,6 +3093,26 @@ test "PageList erase" { try testing.expect(s.pages.first == s.pages.last); } +test "PageList erase reaccounts page size" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const start_size = s.page_size; + + // Grow so we take up at least 5 pages. + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 5) |_| { + _ = try s.grow(); + } + try testing.expect(s.page_size > start_size); + + // Erase the entire history, we should be back to just our active set. + s.eraseRows(.{ .history = .{} }, null); + try testing.expectEqual(start_size, s.page_size); +} + test "PageList erase row with tracked pin resets to top-left" { const testing = std.testing; const alloc = testing.allocator; From fdbda5365e1d8f53f843a77bb5e756a921c8773b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 15:49:18 -0800 Subject: [PATCH 247/428] terminal: do not set selection manually --- src/terminal/Screen.zig | 9 +++++++-- src/terminal/Terminal.zig | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fd0436b6e2..7185255ecc 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -868,8 +868,7 @@ pub fn manualStyleUpdate(self: *Screen) !void { /// is always tracked. pub fn select(self: *Screen, sel_: ?Selection) !void { const sel = sel_ orelse { - if (self.selection) |*old| old.deinit(self); - self.selection = null; + self.clearSelection(); return; }; @@ -882,6 +881,12 @@ pub fn select(self: *Screen, sel_: ?Selection) !void { self.selection = tracked_sel; } +/// Same as select(null) but can't fail. +pub fn clearSelection(self: *Screen) void { + if (self.selection) |*sel| sel.deinit(self); + self.selection = null; +} + /// 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). diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c4c570d387..9145517f7b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2139,7 +2139,7 @@ pub fn alternateScreen( self.screen.charset = old.charset; // Clear our selection - self.screen.selection = null; + self.screen.clearSelection(); // Mark kitty images as dirty so they redraw self.screen.kitty_images.dirty = true; @@ -2174,7 +2174,7 @@ pub fn primaryScreen( self.active_screen = .primary; // Clear our selection - self.screen.selection = null; + self.screen.clearSelection(); // Mark kitty images as dirty so they redraw self.screen.kitty_images.dirty = true; @@ -2203,7 +2203,7 @@ pub fn fullReset(self: *Terminal) void { self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); self.screen.saved_cursor = null; - self.screen.selection = null; + self.screen.clearSelection(); self.screen.kitty_keyboard = .{}; self.screen.protected_mode = .off; self.scrolling_region = .{ From 45c38c6d8bd9da0c530792b922d432eebec2df71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 19:21:35 -0800 Subject: [PATCH 248/428] terminal: clone uses opts struct --- src/terminal/PageList.zig | 118 ++++++++++++++++++++++---------------- src/terminal/Screen.zig | 13 +++-- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b6f913fa09..ceefcd9cdd 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -243,6 +243,21 @@ pub fn deinit(self: *PageList) void { } } +pub const Clone = struct { + /// The top and bottom (inclusive) points of the region to clone. + /// The x coordinate is ignored; the full row is always cloned. + top: point.Point, + bot: ?point.Point = null, + + /// The allocator source for the clone operation. If this is alloc + /// then the cloned pagelist will own and dealloc the memory on deinit. + /// If this is pool then the caller owns the memory. + memory: union(enum) { + alloc: Allocator, + pool: *MemoryPool, + }, +}; + /// Clone this pagelist from the top to bottom (inclusive). /// /// The viewport is always moved to the top-left. @@ -252,36 +267,37 @@ pub fn deinit(self: *PageList) void { /// rows will be added to the bottom of the region to make up the difference. pub fn clone( self: *const PageList, - alloc: Allocator, - top: point.Point, - bot: ?point.Point, + opts: Clone, ) !PageList { - // First, count our pages so our preheat is exactly what we need. - var it = self.pageIterator(.right_down, top, bot); - const page_count: usize = page_count: { - var count: usize = 0; - while (it.next()) |_| count += 1; - break :page_count count; - }; - - // Setup our pools - var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_count); - errdefer pool.deinit(); + var it = self.pageIterator(.right_down, opts.top, opts.bot); + + // Setup our own memory pool if we have to. + var owned_pool: ?MemoryPool = switch (opts.memory) { + .pool => null, + .alloc => |alloc| alloc: { + // First, count our pages so our preheat is exactly what we need. + var it_copy = it; + const page_count: usize = page_count: { + var count: usize = 0; + while (it_copy.next()) |_| count += 1; + break :page_count count; + }; - var result = try self.clonePool(&pool, top, bot); - result.pool_owned = true; - return result; -} + // Setup our pools + break :alloc try MemoryPool.init( + alloc, + std.heap.page_allocator, + page_count, + ); + }, + }; + errdefer if (owned_pool) |*pool| pool.deinit(); -/// Like clone, but specify your own memory pool. This is advanced but -/// lets you avoid expensive syscalls to allocate memory. -pub fn clonePool( - self: *const PageList, - pool: *MemoryPool, - top: point.Point, - bot: ?point.Point, -) !PageList { - var it = self.pageIterator(.right_down, top, bot); + // Create our memory pool we use + const pool: *MemoryPool = switch (opts.memory) { + .pool => |v| v, + .alloc => &owned_pool.?, + }; // Copy our pages var page_list: List = .{}; @@ -333,7 +349,10 @@ pub fn clonePool( var result: PageList = .{ .pool = pool.*, - .pool_owned = false, + .pool_owned = switch (opts.memory) { + .pool => false, + .alloc => true, + }, .pages = page_list, .page_size = PagePool.item_size * page_count, .max_size = self.max_size, @@ -3272,7 +3291,10 @@ test "PageList clone" { defer s.deinit(); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); - var s2 = try s.clone(alloc, .{ .screen = .{} }, null); + var s2 = try s.clone(.{ + .top = .{ .screen = .{} }, + .memory = .{ .alloc = alloc }, + }); defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); } @@ -3286,11 +3308,11 @@ test "PageList clone partial trimmed right" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone( - alloc, - .{ .screen = .{} }, - .{ .screen = .{ .y = 39 } }, - ); + var s2 = try s.clone(.{ + .top = .{ .screen = .{} }, + .bot = .{ .screen = .{ .y = 39 } }, + .memory = .{ .alloc = alloc }, + }); defer s2.deinit(); try testing.expectEqual(@as(usize, 40), s2.totalRows()); } @@ -3304,11 +3326,10 @@ test "PageList clone partial trimmed left" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone( - alloc, - .{ .screen = .{ .y = 10 } }, - null, - ); + var s2 = try s.clone(.{ + .top = .{ .screen = .{ .y = 10 } }, + .memory = .{ .alloc = alloc }, + }); defer s2.deinit(); try testing.expectEqual(@as(usize, 40), s2.totalRows()); } @@ -3322,11 +3343,11 @@ test "PageList clone partial trimmed both" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone( - alloc, - .{ .screen = .{ .y = 10 } }, - .{ .screen = .{ .y = 35 } }, - ); + var s2 = try s.clone(.{ + .top = .{ .screen = .{ .y = 10 } }, + .bot = .{ .screen = .{ .y = 35 } }, + .memory = .{ .alloc = alloc }, + }); defer s2.deinit(); try testing.expectEqual(@as(usize, 26), s2.totalRows()); } @@ -3339,11 +3360,10 @@ test "PageList clone less than active" { defer s.deinit(); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); - var s2 = try s.clone( - alloc, - .{ .active = .{ .y = 5 } }, - null, - ); + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + }); defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 7185255ecc..3e2bf12cc3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -216,10 +216,15 @@ pub fn clonePool( 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); + var pages = try self.pages.clone(.{ + .top = top, + .bot = bot, + .memory = if (pool) |p| .{ + .pool = p, + } else .{ + .alloc = alloc, + }, + }); errdefer pages.deinit(); return .{ From 434f01e25df8b85fbb850ace60c3dd64c13d18f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 19:37:46 -0800 Subject: [PATCH 249/428] terminal: PageList.Clone can remap tracked pins --- src/terminal/PageList.zig | 128 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ceefcd9cdd..c05bb9fcf6 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -256,6 +256,17 @@ pub const Clone = struct { alloc: Allocator, pool: *MemoryPool, }, + + // If this is non-null then cloning will attempt to remap the tracked + // pins into the new cloned area and will keep track of the old to + // new mapping in this map. If this is null, the cloned pagelist will + // not retain any previously tracked pins except those required for + // internal operations. + // + // Any pins not present in the map were not remapped. + tracked_pins: ?*TrackedPinsRemap = null, + + pub const TrackedPinsRemap = std.AutoHashMap(*Pin, *Pin); }; /// Clone this pagelist from the top to bottom (inclusive). @@ -299,6 +310,13 @@ pub fn clone( .alloc => &owned_pool.?, }; + // Our viewport pin is always undefined since our viewport in a clones + // goes back to the top + const viewport_pin = try pool.pins.create(); + var tracked_pins: PinSet = .{}; + errdefer tracked_pins.deinit(pool.alloc); + try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); + // Copy our pages var page_list: List = .{}; var total_rows: usize = 0; @@ -314,6 +332,22 @@ pub fn clone( // If this is a full page then we're done. if (chunk.fullPage()) { total_rows += page.data.size.rows; + + // Updating tracked pins is easy, we just change the page + // pointer but all offsets remain the same. + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + continue; } @@ -324,6 +358,22 @@ pub fn clone( if (chunk.start == 0) { page.data.size.rows = @intCast(chunk.end); total_rows += chunk.end; + + // Updating tracked pins for the pins that are in the shortened chunk. + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y >= chunk.end) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + continue; } @@ -340,12 +390,24 @@ pub fn clone( } page.data.size.rows = @intCast(len); total_rows += len; - } - // Our viewport pin is always undefined since our viewport in a clones - // goes back to the top - const viewport_pin = try pool.pins.create(); - errdefer pool.pins.destroy(viewport_pin); + // Updating tracked pins + if (opts.tracked_pins) |remap| { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != chunk.page or + p.y < chunk.start or + p.y >= chunk.end) continue; + const new_p = try pool.pins.create(); + new_p.* = p.*; + new_p.page = page; + new_p.y -= chunk.start; + try remap.putNoClobber(p, new_p); + try tracked_pins.putNoClobber(pool.alloc, new_p, {}); + } + } + } var result: PageList = .{ .pool = pool.*, @@ -358,7 +420,7 @@ pub fn clone( .max_size = self.max_size, .cols = self.cols, .rows = self.rows, - .tracked_pins = .{}, // TODO + .tracked_pins = tracked_pins, .viewport = .{ .top = {} }, .viewport_pin = viewport_pin, }; @@ -3368,6 +3430,60 @@ test "PageList clone less than active" { try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); } +test "PageList clone remap tracked pin" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Put a tracked pin in the screen + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 6 } }).?); + defer s.untrackPin(p); + + var pin_remap = Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + .tracked_pins = &pin_remap, + }); + defer s2.deinit(); + + // We should be able to find our tracked pin + const p2 = pin_remap.get(p).?; + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s2.pointFromPin(.active, p2.*).?, + ); +} + +test "PageList clone remap tracked pin not in cloned area" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Put a tracked pin in the screen + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?); + defer s.untrackPin(p); + + var pin_remap = Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + var s2 = try s.clone(.{ + .top = .{ .active = .{ .y = 5 } }, + .memory = .{ .alloc = alloc }, + .tracked_pins = &pin_remap, + }); + defer s2.deinit(); + + // We should be able to find our tracked pin + try testing.expect(pin_remap.get(p) == null); +} + test "PageList resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; From ef6bb1de647c4f89631d51d70b38c794a690e50e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:00:05 -0800 Subject: [PATCH 250/428] terminal: Screen clone preserves cursor --- src/terminal/Screen.zig | 81 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3e2bf12cc3..a9210d25e3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -216,6 +216,12 @@ pub fn clonePool( top: point.Point, bot: ?point.Point, ) !Screen { + // Create a tracked pin remapper for our selection and cursor. Note + // that we may want to expose this generally in the future but at the + // time of doing this we don't need to. + var pin_remap = PageList.Clone.TrackedPinsRemap.init(alloc); + defer pin_remap.deinit(); + var pages = try self.pages.clone(.{ .top = top, .bot = bot, @@ -224,17 +230,43 @@ pub fn clonePool( } else .{ .alloc = alloc, }, + .tracked_pins = &pin_remap, }); errdefer pages.deinit(); + // Find our cursor. If the cursor isn't in the cloned area, we move it + // to the top-left arbitrarily because a screen must have SOME cursor. + const cursor: Cursor = cursor: { + if (pin_remap.get(self.cursor.page_pin)) |p| remap: { + const page_rac = p.rowAndCell(); + const pt = pages.pointFromPin(.active, p.*) orelse break :remap; + break :cursor .{ + .x = @intCast(pt.active.x), + .y = @intCast(pt.active.y), + .page_pin = p, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }; + } + + const page_pin = try pages.trackPin(.{ .page = pages.pages.first.? }); + const page_rac = page_pin.rowAndCell(); + break :cursor .{ + .x = 0, + .y = 0, + .page_pin = page_pin, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }; + }; + return .{ .alloc = alloc, .pages = pages, .no_scrollback = self.no_scrollback, + .cursor = cursor, // TODO: selection - // TODO: let's make this reasonble - .cursor = undefined, }; } @@ -2431,6 +2463,8 @@ test "Screen: clone" { defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); // Clone var s2 = try s.clone(alloc, .{ .active = .{} }, null); @@ -2440,6 +2474,8 @@ test "Screen: clone" { defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 1), s2.cursor.y); // Write to s1, should not be in s2 try s.testWriteString("\n34567"); @@ -2453,6 +2489,8 @@ test "Screen: clone" { defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 1), s2.cursor.y); } test "Screen: clone partial" { @@ -2467,6 +2505,8 @@ test "Screen: clone partial" { defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); // Clone var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); @@ -2476,6 +2516,43 @@ test "Screen: clone partial" { defer alloc.free(contents); try testing.expectEqualStrings("2EFGH", contents); } + + // Cursor is shifted since we cloned partial + try testing.expectEqual(@as(usize, 5), s2.cursor.x); + try testing.expectEqual(@as(usize, 0), s2.cursor.y); +} + +test "Screen: clone partial cursor out of bounds" { + 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); + } + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 0 } }, + ); + defer s2.deinit(); + { + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } + + // Cursor is shifted since we cloned partial + try testing.expectEqual(@as(usize, 0), s2.cursor.x); + try testing.expectEqual(@as(usize, 0), s2.cursor.y); } test "Screen: clone basic" { From ba4f2eeee209e62eaa1940cb0e59d29ddd014aeb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:20:17 -0800 Subject: [PATCH 251/428] terminal: Screen.clone preserves selection --- src/terminal/Screen.zig | 139 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a9210d25e3..185c5b00ef 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -260,13 +260,37 @@ pub fn clonePool( }; }; + // Preserve our selection if we have one. + const sel: ?Selection = if (self.selection) |sel| sel: { + assert(sel.tracked()); + const start_pin = pin_remap.get(sel.bounds.tracked.start) orelse start: { + // No start means it is outside the cloned area. We change it + // to the top-left. + break :start try pages.trackPin(.{ .page = pages.pages.first.? }); + }; + const end_pin = pin_remap.get(sel.bounds.tracked.end) orelse end: { + // No end means it is outside the cloned area. We change it + // to the bottom-right. + break :end try pages.trackPin(pages.pin(.{ .active = .{ + .x = pages.cols - 1, + .y = pages.rows - 1, + } }) orelse break :sel null); + }; + break :sel .{ + .bounds = .{ .tracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = sel.rectangle, + }; + } else null; + return .{ .alloc = alloc, .pages = pages, .no_scrollback = self.no_scrollback, .cursor = cursor, - - // TODO: selection + .selection = sel, }; } @@ -2555,6 +2579,117 @@ test "Screen: clone partial cursor out of bounds" { try testing.expectEqual(@as(usize, 0), s2.cursor.y); } +test "Screen: clone contains full selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{} }, + null, + ); + defer s2.deinit(); + + // Our selection should remain valid + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: clone contains selection start cutoff" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + null, + ); + defer s2.deinit(); + + // Our selection should remain valid + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: clone contains selection end cutoff" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 2, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; From ff0e07a907dc8fc08bda04ad860e335a408a5ae4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:21:42 -0800 Subject: [PATCH 252/428] renderer/metal: re-enable the cursor, it works --- src/renderer/Metal.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f9393fc461..d0ff577ad3 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1738,8 +1738,7 @@ fn rebuildCells( break :cursor_style; } - _ = cursor_style; - //_ = self.addCursor(screen, cursor_style); + _ = self.addCursor(screen, cursor_style); if (cursor_cell) |*cell| { if (cell.mode == .fg) { cell.color = if (self.config.cursor_text) |txt| From cf885b89983100aa94a2e2117d91ac016056016d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:37:33 -0800 Subject: [PATCH 253/428] font/shaper: fix style for runs --- src/font/shaper/run.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index c1f483778b..b5c29ec3f6 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -55,7 +55,7 @@ pub const RunIterator = struct { try self.hooks.prepare(); // Let's get our style that we'll expect for the run. - const style = self.row.style(&cells[0]); + const style = self.row.style(&cells[self.i]); // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; From 21f09a91594dab6064f679d81ecd798feca86104 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:45:39 -0800 Subject: [PATCH 254/428] remove point.Viewport --- src/Surface.zig | 6 +++--- src/renderer/State.zig | 2 +- src/terminal/PageList.zig | 2 +- src/terminal/point.zig | 11 ++--------- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index b0cc4b7dbc..ccfbd732cc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -172,7 +172,7 @@ const Mouse = struct { left_click_time: std.time.Instant = undefined, /// The last x/y sent for mouse reports. - event_point: ?terminal.point.Viewport = null, + event_point: ?terminal.point.Coordinate = null, /// Pending scroll amounts for high-precision scrolls pending_scroll_x: f64 = 0, @@ -186,7 +186,7 @@ const Mouse = struct { /// The last x/y in the cursor position for links. We use this to /// only process link hover events when the mouse actually moves cells. - link_point: ?terminal.point.Viewport = null, + link_point: ?terminal.point.Coordinate = null, }; /// The configuration that a surface has, this is copied from the main @@ -2886,7 +2886,7 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { if (report) try self.reportColorScheme(); } -fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Viewport { +fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate { // xpos/ypos need to be adjusted for window padding // (i.e. "window-padding-*" settings. const pad = if (self.config.window_padding_balance) diff --git a/src/renderer/State.zig b/src/renderer/State.zig index 6c69d30a95..6bff5b2192 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -34,7 +34,7 @@ pub const Mouse = struct { /// The point on the viewport where the mouse currently is. We use /// viewport points to avoid the complexity of mapping the mouse to /// the renderer state. - point: ?terminal.point.Viewport = null, + point: ?terminal.point.Coordinate = null, /// The mods that are currently active for the last mouse event. /// This could really just be mods in general and we probably will diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c05bb9fcf6..96368109f0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1621,7 +1621,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point const tl = self.getTopLeft(tag); // Count our first page which is special because it may be partial. - var coord: point.Point.Coordinate = .{ .x = p.x }; + var coord: point.Coordinate = .{ .x = p.x }; if (p.page == tl.page) { // If our top-left is after our y then we're outside the range. if (tl.y > p.y) return null; diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 19ab367771..41b7a35585 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -53,11 +53,6 @@ pub const Point = union(Tag) { screen: Coordinate, history: Coordinate, - pub const Coordinate = struct { - x: usize = 0, - y: usize = 0, - }; - pub fn coord(self: Point) Coordinate { return switch (self) { .active, @@ -69,13 +64,11 @@ pub const Point = union(Tag) { } }; -/// A point in the terminal that is always in the viewport area. -/// TODO(paged-terminal): remove -pub const Viewport = struct { +pub const Coordinate = struct { x: usize = 0, y: usize = 0, - pub fn eql(self: Viewport, other: Viewport) bool { + pub fn eql(self: Coordinate, other: Coordinate) bool { return self.x == other.x and self.y == other.y; } }; From 9c2a5bccc1e0570e4e2706d5f76514f1e499d26c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Mar 2024 20:48:46 -0800 Subject: [PATCH 255/428] terminal: page size should be accounted every creation --- src/terminal/PageList.zig | 9 +++++++-- src/terminal/kitty/graphics_storage.zig | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 96368109f0..808ec84409 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1387,8 +1387,8 @@ pub fn grow(self: *PageList) !?*List.Node { self.pages.append(next_page); next_page.data.size.rows = 1; - // Accounting - self.page_size += PagePool.item_size; + // We should never be more than our max size here because we've + // verified the case above. assert(self.page_size <= self.max_size); return next_page; @@ -1412,6 +1412,11 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { }; page.data.size.rows = 0; + // Accumulate page size now. We don't assert or check max size because + // we may exceed it here temporarily as we are allocating pages before + // destroy. + self.page_size += PagePool.item_size; + return page; } diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index d84a0a1223..534d1b33f0 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -635,7 +635,7 @@ pub const ImageStorage = struct { // Our pin for the placement fn trackPin( t: *terminal.Terminal, - pt: point.Point.Coordinate, + pt: point.Coordinate, ) !*PageList.Pin { return try t.screen.pages.trackPin(t.screen.pages.pin(.{ .active = pt, From 9830aacc1c4e78f313c9888555c35607352cf2af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 09:59:24 -0700 Subject: [PATCH 256/428] terminal: pagelist resize handles soft-wrap across pages --- src/terminal/PageList.zig | 587 +++++++++++++++++++++++--------------- 1 file changed, 362 insertions(+), 225 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 808ec84409..f36a6abd84 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -536,17 +536,21 @@ fn resizeCols( // Fast-path: none of our rows are wrapped. In this case we can // treat this like a no-reflow resize. This only applies if we // are growing columns. - if (cols > self.cols) { + if (cols > self.cols) no_reflow: { const page = &chunk.page.data; const rows = page.rows.ptr(page.memory)[0..page.size.rows]; - const wrapped = wrapped: for (rows) |row| { - assert(!row.wrap_continuation); // TODO - if (row.wrap) break :wrapped true; - } else false; - if (!wrapped) { - try self.resizeWithoutReflowGrowCols(cap, chunk); - continue; + + // If our first row is a wrap continuation, then we have to + // reflow since we're continuing a wrapped line. + if (rows[0].wrap_continuation) break :no_reflow; + + // If any row is soft-wrapped then we have to reflow + for (rows) |row| { + if (row.wrap) break :no_reflow; } + + try self.resizeWithoutReflowGrowCols(cap, chunk); + continue; } // Note: we can do a fast-path here if all of our rows in this @@ -628,6 +632,12 @@ const ReflowCursor = struct { }; } + /// True if this cursor is at the bottom of the page by capacity, + /// i.e. we can't scroll anymore. + fn bottom(self: *const ReflowCursor) bool { + return self.y == self.page.capacity.rows - 1; + } + fn cursorForward(self: *ReflowCursor) void { if (self.x == self.page.size.cols - 1) { self.pending_wrap = true; @@ -638,6 +648,11 @@ const ReflowCursor = struct { } } + fn cursorDown(self: *ReflowCursor) void { + assert(self.y + 1 < self.page.size.rows); + self.cursorAbsolute(self.x, self.y + 1); + } + fn cursorScroll(self: *ReflowCursor) void { // Scrolling requires that we're on the bottom of our page. // We also assert that we have capacity because reflow always @@ -708,18 +723,17 @@ const ReflowCursor = struct { /// /// Note a couple edge cases: /// -/// 1. If the first set of rows of this page are a wrap continuation, then -/// we will reflow the continuation rows but will not traverse back to -/// find the initial wrap. +/// 1. All initial rows that are wrap continuations are ignored. If you +/// want to reflow these lines you must reflow the page with the +/// initially wrapped line. /// /// 2. If the last row is wrapped then we will traverse forward to reflow -/// all the continuation rows. +/// all the continuation rows. This follows from #1. /// -/// As a result of the above edge cases, the pagelist may end up removing -/// an indefinite number of pages. In the most pathological cases (the screen -/// is one giant wrapped line), this can be a very expensive operation. That -/// doesn't really happen in typical terminal usage so its not a case we -/// optimize for today. Contributions welcome to optimize this. +/// Despite the edge cases above, this will only ever remove the initial +/// node, so that this can be called within a pageIterator. This is a weird +/// detail that will surely cause bugs one day so we should look into fixing +/// it. :) /// /// Conceptually, this is a simple process: we're effectively traversing /// the old page and rewriting into the new page as if it were a text editor. @@ -729,17 +743,37 @@ const ReflowCursor = struct { fn reflowPage( self: *PageList, cap: Capacity, - node: *List.Node, + initial_node: *List.Node, ) !void { // The cursor tracks where we are in the source page. - var src_cursor = ReflowCursor.init(&node.data); + var src_node = initial_node; + var src_cursor = ReflowCursor.init(&src_node.data); + + // This is set to true when we're in the middle of completing a wrap + // from the initial page. If this is true, the moment we see a non-wrapped + // row we are done. + var src_completing_wrap = false; // This is used to count blank lines so that we don't copy those. var blank_lines: usize = 0; + // Skip initially reflowed lines + if (src_cursor.page_row.wrap_continuation) { + while (src_cursor.page_row.wrap_continuation) { + // If this entire page was continuations then we can remove it. + if (src_cursor.y == src_cursor.page.size.rows - 1) { + self.pages.remove(initial_node); + self.destroyPage(initial_node); + return; + } + + src_cursor.cursorDown(); + } + } + // Our new capacity when growing columns may also shrink rows. So we // need to do a loop in order to potentially make multiple pages. - while (true) { + dst_loop: while (true) { // Create our new page and our cursor restarts at 0,0 in the new page. // The new page always starts with a size of 1 because we know we have // at least one row to copy from the src. @@ -753,235 +787,264 @@ fn reflowPage( // Our new page goes before our src node. This will append it to any // previous pages we've created. - self.pages.insertBefore(node, dst_node); - - // Continue traversing the source until we're out of space in our - // destination or we've copied all our intended rows. - for (src_cursor.y..src_cursor.page.size.rows) |src_y| { - const prev_wrap = src_cursor.page_row.wrap; - src_cursor.cursorAbsolute(0, @intCast(src_y)); - - // Trim trailing empty cells if the row is not wrapped. If the - // row is wrapped then we don't trim trailing empty cells because - // the empty cells can be meaningful. - const trailing_empty = src_cursor.countTrailingEmptyCells(); - const cols_len = cols_len: { - var cols_len = src_cursor.page.size.cols - trailing_empty; - if (cols_len > 0) break :cols_len cols_len; - - // If a tracked pin is in this row then we need to keep it - // even if it is empty, because it is somehow meaningful - // (usually the screen cursor), but we do trim the cells - // down to the desired size. - // - // The reason we do this logic is because if you do a scroll - // clear (i.e. move all active into scrollback and reset - // the screen), the cursor is on the top line again with - // an empty active. If you resize to a smaller col size we - // don't want to "pull down" all the scrollback again. The - // user expects we just shrink the active area. - var it = self.tracked_pins.keyIterator(); - while (it.next()) |p_ptr| { - const p = p_ptr.*; - if (&p.page.data != src_cursor.page or - p.y != src_cursor.y) continue; - - // If our tracked pin is outside our resized cols, we - // trim it to the last col, we don't want to wrap blanks. - if (p.x >= cap.cols) p.x = cap.cols - 1; - - // We increase our col len to at least include this pin - cols_len = @max(cols_len, p.x + 1); - } + self.pages.insertBefore(initial_node, dst_node); + + src_loop: while (true) { + // Continue traversing the source until we're out of space in our + // destination or we've copied all our intended rows. + const started_completing_wrap = src_completing_wrap; + for (src_cursor.y..src_cursor.page.size.rows) |src_y| { + // If we started completing a wrap and our flag is no longer true + // then we completed it and we can exit the loop. + if (started_completing_wrap and !src_completing_wrap) break; + + const prev_wrap = src_cursor.page_row.wrap; + src_cursor.cursorAbsolute(0, @intCast(src_y)); + + // Trim trailing empty cells if the row is not wrapped. If the + // row is wrapped then we don't trim trailing empty cells because + // the empty cells can be meaningful. + const trailing_empty = src_cursor.countTrailingEmptyCells(); + const cols_len = cols_len: { + var cols_len = src_cursor.page.size.cols - trailing_empty; + if (cols_len > 0) break :cols_len cols_len; + + // If a tracked pin is in this row then we need to keep it + // even if it is empty, because it is somehow meaningful + // (usually the screen cursor), but we do trim the cells + // down to the desired size. + // + // The reason we do this logic is because if you do a scroll + // clear (i.e. move all active into scrollback and reset + // the screen), the cursor is on the top line again with + // an empty active. If you resize to a smaller col size we + // don't want to "pull down" all the scrollback again. The + // user expects we just shrink the active area. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y) continue; + + // If our tracked pin is outside our resized cols, we + // trim it to the last col, we don't want to wrap blanks. + if (p.x >= cap.cols) p.x = cap.cols - 1; + + // We increase our col len to at least include this pin + cols_len = @max(cols_len, p.x + 1); + } - if (cols_len == 0) { - // If the row is empty, we don't copy it. We count it as a - // blank line and continue to the next row. - blank_lines += 1; - continue; - } + if (cols_len == 0) { + // If the row is empty, we don't copy it. We count it as a + // blank line and continue to the next row. + blank_lines += 1; + continue; + } - break :cols_len cols_len; - }; + break :cols_len cols_len; + }; - // We have data, if we have blank lines we need to create them first. - for (0..blank_lines) |_| { - // TODO: cursor in here - dst_cursor.cursorScroll(); - } + // We have data, if we have blank lines we need to create them first. + for (0..blank_lines) |i| { + // If we're at the bottom we can't fit anymore into this page, + // so we need to reloop and create a new page. + if (dst_cursor.bottom()) { + blank_lines -= i; + continue :dst_loop; + } - if (src_y > 0) { - // We're done with this row, if this row isn't wrapped, we can - // move our destination cursor to the next row. - // - // The blank_lines == 0 condition is because if we were prefixed - // with blank lines, we handled the scroll already above. - if (!prev_wrap and blank_lines == 0) { + // TODO: cursor in here dst_cursor.cursorScroll(); } - dst_cursor.copyRowMetadata(src_cursor.page_row); - } - - // Reset our blank line count since handled it all above. - blank_lines = 0; - - for (src_cursor.x..cols_len) |src_x| { - assert(src_cursor.x == src_x); - - // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} cp={u}", .{ - // src_cursor.y, - // src_cursor.x, - // dst_cursor.y, - // dst_cursor.x, - // src_cursor.page_cell.content.codepoint, - // }); - - // If we have a wide char at the end of our page we need - // to insert a spacer head and wrap. - if (cap.cols > 1 and - src_cursor.page_cell.wide == .wide and - dst_cursor.x == cap.cols - 1) - { - self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); - - dst_cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_head, - }; - dst_cursor.cursorForward(); - } - - // If we have a spacer head and we're not at the end then - // we want to unwrap it and eliminate the head. - if (cap.cols > 1 and - src_cursor.page_cell.wide == .spacer_head and - dst_cursor.x != cap.cols - 1) - { - self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); - src_cursor.cursorForward(); - continue; - } + if (src_y > 0) { + // We're done with this row, if this row isn't wrapped, we can + // move our destination cursor to the next row. + // + // The blank_lines == 0 condition is because if we were prefixed + // with blank lines, we handled the scroll already above. + if (!prev_wrap and blank_lines == 0) { + dst_cursor.cursorScroll(); + } - if (dst_cursor.pending_wrap) { - dst_cursor.page_row.wrap = true; - dst_cursor.cursorScroll(); - dst_cursor.page_row.wrap_continuation = true; dst_cursor.copyRowMetadata(src_cursor.page_row); } - // A rare edge case. If we're resizing down to 1 column - // and the source is a non-narrow character, we reset the - // cell to a narrow blank and we skip to the next cell. - if (cap.cols == 1 and src_cursor.page_cell.wide != .narrow) { - switch (src_cursor.page_cell.wide) { - .narrow => unreachable, - - // Wide char, we delete it, reset it to narrow, - // and skip forward. - .wide => { - dst_cursor.page_cell.content.codepoint = 0; - dst_cursor.page_cell.wide = .narrow; - src_cursor.cursorForward(); - continue; - }, - - // Skip spacer tails since we should've already - // handled them in the previous cell. - .spacer_tail => {}, - - // TODO: test? - .spacer_head => {}, + // Reset our blank line count since handled it all above. + blank_lines = 0; + + for (src_cursor.x..cols_len) |src_x| { + assert(src_cursor.x == src_x); + + // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} cp={}", .{ + // src_cursor.y, + // src_cursor.x, + // dst_cursor.y, + // dst_cursor.x, + // src_cursor.page_cell.content.codepoint, + // }); + + // If we have a wide char at the end of our page we need + // to insert a spacer head and wrap. + if (cap.cols > 1 and + src_cursor.page_cell.wide == .wide and + dst_cursor.x == cap.cols - 1) + { + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + + dst_cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + dst_cursor.cursorForward(); } - } else { - switch (src_cursor.page_cell.content_tag) { - // These are guaranteed to have no styling data and no - // graphemes, a fast path. - .bg_color_palette, - .bg_color_rgb, - => { - assert(!src_cursor.page_cell.hasStyling()); - assert(!src_cursor.page_cell.hasGrapheme()); - dst_cursor.page_cell.* = src_cursor.page_cell.*; - }, - - .codepoint => { - dst_cursor.page_cell.* = src_cursor.page_cell.*; - }, - - .codepoint_grapheme => { - // We copy the cell like normal but we have to reset the - // tag because this is used for fast-path detection in - // appendGrapheme. - dst_cursor.page_cell.* = src_cursor.page_cell.*; - dst_cursor.page_cell.content_tag = .codepoint; - - // Copy the graphemes - const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; - for (src_cps) |cp| { - try dst_cursor.page.appendGrapheme( - dst_cursor.page_row, - dst_cursor.page_cell, - cp, - ); - } - }, + + // If we have a spacer head and we're not at the end then + // we want to unwrap it and eliminate the head. + if (cap.cols > 1 and + src_cursor.page_cell.wide == .spacer_head and + dst_cursor.x != cap.cols - 1) + { + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + src_cursor.cursorForward(); + continue; } - // If the source cell has a style, we need to copy it. - if (src_cursor.page_cell.style_id != stylepkg.default_id) { - const src_style = src_cursor.page.styles.lookupId( - src_cursor.page.memory, - src_cursor.page_cell.style_id, - ).?.*; - - const dst_md = try dst_cursor.page.styles.upsert( - dst_cursor.page.memory, - src_style, - ); - dst_md.ref += 1; - dst_cursor.page_cell.style_id = dst_md.id; - dst_cursor.page_row.styled = true; + if (dst_cursor.pending_wrap) { + dst_cursor.page_row.wrap = true; + dst_cursor.cursorScroll(); + dst_cursor.page_row.wrap_continuation = true; + dst_cursor.copyRowMetadata(src_cursor.page_row); } - } - // If our original cursor was on this page, this x/y then - // we need to update to the new location. - self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + // A rare edge case. If we're resizing down to 1 column + // and the source is a non-narrow character, we reset the + // cell to a narrow blank and we skip to the next cell. + if (cap.cols == 1 and src_cursor.page_cell.wide != .narrow) { + switch (src_cursor.page_cell.wide) { + .narrow => unreachable, + + // Wide char, we delete it, reset it to narrow, + // and skip forward. + .wide => { + dst_cursor.page_cell.content.codepoint = 0; + dst_cursor.page_cell.wide = .narrow; + src_cursor.cursorForward(); + continue; + }, + + // Skip spacer tails since we should've already + // handled them in the previous cell. + .spacer_tail => {}, + + // TODO: test? + .spacer_head => {}, + } + } else { + switch (src_cursor.page_cell.content_tag) { + // These are guaranteed to have no styling data and no + // graphemes, a fast path. + .bg_color_palette, + .bg_color_rgb, + => { + assert(!src_cursor.page_cell.hasStyling()); + assert(!src_cursor.page_cell.hasGrapheme()); + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint => { + dst_cursor.page_cell.* = src_cursor.page_cell.*; + }, + + .codepoint_grapheme => { + // We copy the cell like normal but we have to reset the + // tag because this is used for fast-path detection in + // appendGrapheme. + dst_cursor.page_cell.* = src_cursor.page_cell.*; + dst_cursor.page_cell.content_tag = .codepoint; + + // Copy the graphemes + const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; + for (src_cps) |cp| { + try dst_cursor.page.appendGrapheme( + dst_cursor.page_row, + dst_cursor.page_cell, + cp, + ); + } + }, + } + + // If the source cell has a style, we need to copy it. + if (src_cursor.page_cell.style_id != stylepkg.default_id) { + const src_style = src_cursor.page.styles.lookupId( + src_cursor.page.memory, + src_cursor.page_cell.style_id, + ).?.*; + + const dst_md = try dst_cursor.page.styles.upsert( + dst_cursor.page.memory, + src_style, + ); + dst_md.ref += 1; + dst_cursor.page_cell.style_id = dst_md.id; + dst_cursor.page_row.styled = true; + } + } - // Move both our cursors forward - src_cursor.cursorForward(); - dst_cursor.cursorForward(); - } else cursor: { - // We made it through all our source columns. As a final edge - // case, if our cursor is in one of the blanks, we update it - // to the edge of this page. + // If our original cursor was on this page, this x/y then + // we need to update to the new location. + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); - // If we have no trailing empty cells, it can't be in the blanks. - if (trailing_empty == 0) break :cursor; + // Move both our cursors forward + src_cursor.cursorForward(); + dst_cursor.cursorForward(); + } else cursor: { + // We made it through all our source columns. As a final edge + // case, if our cursor is in one of the blanks, we update it + // to the edge of this page. + + // If we are in wrap completion mode and this row is not wrapped + // then we are done and we can gracefully exit our y loop. + if (src_completing_wrap and !src_cursor.page_row.wrap) { + assert(started_completing_wrap); + src_completing_wrap = false; + } - // Update all our tracked pins - var it = self.tracked_pins.keyIterator(); - while (it.next()) |p_ptr| { - const p = p_ptr.*; - if (&p.page.data != src_cursor.page or - p.y != src_cursor.y or - p.x < cols_len) continue; + // If we have no trailing empty cells, it can't be in the blanks. + if (trailing_empty == 0) break :cursor; + + // Update all our tracked pins + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x < cols_len) continue; - p.page = dst_node; - p.y = dst_cursor.y; + p.page = dst_node; + p.y = dst_cursor.y; + } } } - } else { - // We made it through all our source rows, we're done. - break; + + // If we're still in a wrapped line at the end of our page, + // we traverse forward and continue reflowing until we complete + // this entire line. + if (src_cursor.page_row.wrap) { + src_completing_wrap = true; + src_node = src_node.next.?; + src_cursor = ReflowCursor.init(&src_node.data); + continue :src_loop; + } + + // We are not on a wrapped line, we're truly done. + self.pages.remove(initial_node); + self.destroyPage(initial_node); + return; } } - - // Finally, remove the old page. - self.pages.remove(node); - self.destroyPage(node); } /// This updates the cursor offset if the cursor is exactly on the cell @@ -4183,6 +4246,80 @@ test "PageList resize reflow more cols wrapped rows" { } } +test "PageList resize reflow more cols wrap across page boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(1); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // We expect one extra row since we unwrapped a row we need to resize + // to make our active area. + const end_rows = s.totalRows(); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, end_rows), s.totalRows()); + + { + const p = s.pin(.{ .active = .{ .y = 9 } }).?; + const row = p.rowAndCell().row; + try testing.expect(!row.wrap); + + const cells = p.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint); + } +} + test "PageList resize reflow more cols cursor in wrapped row" { const testing = std.testing; const alloc = testing.allocator; From 36c93ac968b611c163fcb422d2647820eb75f421 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 10:24:30 -0700 Subject: [PATCH 257/428] terminal: Pagelist reflow cursor in blank cell wrapped properly --- src/terminal/PageList.zig | 51 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f36a6abd84..e708e08dc2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -807,7 +807,22 @@ fn reflowPage( const trailing_empty = src_cursor.countTrailingEmptyCells(); const cols_len = cols_len: { var cols_len = src_cursor.page.size.cols - trailing_empty; - if (cols_len > 0) break :cols_len cols_len; + + if (cols_len > 0) { + // We want to update any tracked pins that are in our + // trailing empty cells to the last col. We don't + // want to wrap blanks. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (&p.page.data != src_cursor.page or + p.y != src_cursor.y or + p.x < cols_len) continue; + if (p.x >= cap.cols) p.x = cap.cols - 1; + } + + break :cols_len cols_len; + } // If a tracked pin is in this row then we need to keep it // even if it is empty, because it is somehow meaningful @@ -5118,6 +5133,40 @@ test "PageList resize reflow less cols cursor in final blank cell" { } }, s.pointFromPin(.active, p.*).?); } +test "PageList resize reflow less cols cursor in wrapped blank cell" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 6, 2, null); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..2) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in the history + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pointFromPin(.active, p.*).?); +} + test "PageList resize reflow less cols blank lines" { const testing = std.testing; const alloc = testing.allocator; From 48d40793e7873b525e9e9bd0fd7d35917a5961f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 14:31:10 -0700 Subject: [PATCH 258/428] terminal: bring back clearPromptForResize, with tests! --- src/terminal/Screen.zig | 94 +++++++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 13 ++---- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 185c5b00ef..c5e7cfefb9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -677,6 +677,56 @@ pub fn clearUnprotectedCells( } } +/// Clears the prompt lines if the cursor is currently at a prompt. This +/// clears the entire line. This is used for resizing when the shell +/// handles reflow. +/// +/// The cleared cells are not colored with the current style background +/// color like other clear functions, because this is a special case used +/// for a specific purpose that does not want that behavior. +pub fn clearPrompt(self: *Screen) void { + var found: ?Pin = null; + + // From our cursor, move up and find all prompt lines. + var it = self.cursor.page_pin.rowIterator( + .left_up, + self.pages.pin(.{ .active = .{} }), + ); + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // 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 = p, + + // 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 = p; + 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 we found a prompt, we clear it. + if (found) |top| { + var clear_it = top.rowIterator(.right_down, null); + while (clear_it.next()) |p| { + const row = p.rowAndCell().row; + p.page.data.clearCells(row, 0, p.page.data.size.cols); + } + } +} + /// Returns the blank cell to use when doing terminal operations that /// require preserving the bg color. fn blankCell(self: *const Screen) Cell { @@ -2067,6 +2117,50 @@ test "Screen eraseRows history with more lines" { } } +test "Screen: clearPrompt" { + 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; + s.cursorAbsolute(0, 2); + s.cursor.page_row.semantic_prompt = .input; + } + + s.clearPrompt(); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + +test "Screen: clearPrompt no 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); + + s.clearPrompt(); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9145517f7b..a0224e195b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2051,7 +2051,10 @@ pub fn resize( // If we're making the screen smaller, dealloc the unused items. if (self.active_screen == .primary) { - self.clearPromptForResize(); + if (self.flags.shell_redraws_prompt) { + self.screen.clearPrompt(); + } + if (self.modes.get(.wraparound)) { try self.screen.resize(cols, rows); } else { @@ -2080,14 +2083,6 @@ pub fn resize( }; } -/// If shell_redraws_prompt is true and we're on the primary screen, -/// 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; -} - /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { self.pwd.clearRetainingCapacity(); From 9d6f668c9ac2746f5a443458f9fa601ee527804e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 14:41:01 -0700 Subject: [PATCH 259/428] terminal: resize create new pages as necessary --- src/terminal/PageList.zig | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e708e08dc2..8e9f5c1834 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -749,14 +749,6 @@ fn reflowPage( var src_node = initial_node; var src_cursor = ReflowCursor.init(&src_node.data); - // This is set to true when we're in the middle of completing a wrap - // from the initial page. If this is true, the moment we see a non-wrapped - // row we are done. - var src_completing_wrap = false; - - // This is used to count blank lines so that we don't copy those. - var blank_lines: usize = 0; - // Skip initially reflowed lines if (src_cursor.page_row.wrap_continuation) { while (src_cursor.page_row.wrap_continuation) { @@ -771,6 +763,18 @@ fn reflowPage( } } + // This is set to true when we're in the middle of completing a wrap + // from the initial page. If this is true, the moment we see a non-wrapped + // row we are done. + var src_completing_wrap = false; + + // This is used to count blank lines so that we don't copy those. + var blank_lines: usize = 0; + + // This is set to true when we're wrapping a line that requires a new + // writer page. + var dst_wrap = false; + // Our new capacity when growing columns may also shrink rows. So we // need to do a loop in order to potentially make multiple pages. dst_loop: while (true) { @@ -782,8 +786,11 @@ fn reflowPage( var dst_cursor = ReflowCursor.init(&dst_node.data); dst_cursor.copyRowMetadata(src_cursor.page_row); - // Copy some initial metadata about the row - //dst_cursor.page_row.semantic_prompt = src_cursor.page_row.semantic_prompt; + // Set our wrap state + if (dst_wrap) { + dst_cursor.page_row.wrap_continuation = true; + dst_wrap = false; + } // Our new page goes before our src node. This will append it to any // previous pages we've created. @@ -879,6 +886,7 @@ fn reflowPage( // The blank_lines == 0 condition is because if we were prefixed // with blank lines, we handled the scroll already above. if (!prev_wrap and blank_lines == 0) { + if (dst_cursor.bottom()) continue :dst_loop; dst_cursor.cursorScroll(); } @@ -928,6 +936,10 @@ fn reflowPage( if (dst_cursor.pending_wrap) { dst_cursor.page_row.wrap = true; + if (dst_cursor.bottom()) { + dst_wrap = true; + continue :dst_loop; + } dst_cursor.cursorScroll(); dst_cursor.page_row.wrap_continuation = true; dst_cursor.copyRowMetadata(src_cursor.page_row); From 5b93acaf5f4c77bcf9bf07ffd26a8dbe5cad8987 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 14:56:34 -0700 Subject: [PATCH 260/428] terminal: PageList more resize tests --- src/terminal/PageList.zig | 102 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 8e9f5c1834..47fa669591 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4612,6 +4612,108 @@ test "PageList resize reflow more cols unwrap wide spacer head" { } } +test "PageList resize reflow more cols unwrap wide spacer head across two rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(3, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + test "PageList resize reflow more cols unwrap still requires wide spacer head" { const testing = std.testing; const alloc = testing.allocator; From c0e6eb4bebeb41e6d7eb993d9cf3c493a86e033c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 15:35:53 -0700 Subject: [PATCH 261/428] terminal: PageList resize fix spacer issues with tests --- src/terminal/PageList.zig | 153 +++++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 28 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 47fa669591..4166d3fb80 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -899,40 +899,42 @@ fn reflowPage( for (src_cursor.x..cols_len) |src_x| { assert(src_cursor.x == src_x); - // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} cp={}", .{ + // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={u} wide={}", .{ // src_cursor.y, // src_cursor.x, // dst_cursor.y, // dst_cursor.x, + // dst_cursor.page.size.cols, // src_cursor.page_cell.content.codepoint, + // src_cursor.page_cell.wide, // }); - // If we have a wide char at the end of our page we need - // to insert a spacer head and wrap. - if (cap.cols > 1 and - src_cursor.page_cell.wide == .wide and - dst_cursor.x == cap.cols - 1) - { - self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); - - dst_cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_head, - }; - dst_cursor.cursorForward(); - } - - // If we have a spacer head and we're not at the end then - // we want to unwrap it and eliminate the head. - if (cap.cols > 1 and - src_cursor.page_cell.wide == .spacer_head and - dst_cursor.x != cap.cols - 1) - { - self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); - src_cursor.cursorForward(); - continue; - } + if (cap.cols > 1) switch (src_cursor.page_cell.wide) { + .narrow => {}, + + .wide => if (!dst_cursor.pending_wrap and + dst_cursor.x == cap.cols - 1) + { + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + dst_cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + dst_cursor.cursorForward(); + assert(dst_cursor.pending_wrap); + }, + + .spacer_head => if (dst_cursor.pending_wrap or + dst_cursor.x != cap.cols - 1) + { + assert(src_cursor.x == src_cursor.page.size.cols - 1); + self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node); + continue; + }, + + else => {}, + }; if (dst_cursor.pending_wrap) { dst_cursor.page_row.wrap = true; @@ -4698,7 +4700,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" } { const rac = page.getRowAndCell(3, 0); - try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); } { @@ -5114,6 +5116,101 @@ test "PageList resize reflow less cols cursor in wrapped row" { } }, s.pointFromPin(.active, p.*).?); } +test "PageList resize reflow less cols wraps spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(3, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} test "PageList resize reflow less cols cursor goes to scrollback" { const testing = std.testing; const alloc = testing.allocator; From 27d2903b3cb01691775f26d75b2086f940458b51 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 15:40:18 -0700 Subject: [PATCH 262/428] terminal: don't insert newline across page boundaries --- src/terminal/PageList.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 4166d3fb80..866ce7f447 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -805,7 +805,13 @@ fn reflowPage( // then we completed it and we can exit the loop. if (started_completing_wrap and !src_completing_wrap) break; - const prev_wrap = src_cursor.page_row.wrap; + // We are previously wrapped if we're not on the first row and + // the previous row was wrapped OR if we're on the first row + // but we're not on our initial node it means the last row of + // our previous page was wrapped. + const prev_wrap = + (src_y > 0 and src_cursor.page_row.wrap) or + (src_y == 0 and src_node != initial_node); src_cursor.cursorAbsolute(0, @intCast(src_y)); // Trim trailing empty cells if the row is not wrapped. If the From 3caf6779a503fa61abf1db6a9d75caf5d1f1495f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 16:36:04 -0700 Subject: [PATCH 263/428] terminal: PageList resize blank lines at start of page --- src/terminal/PageList.zig | 93 ++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 866ce7f447..64034e9a41 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -873,16 +873,24 @@ fn reflowPage( }; // We have data, if we have blank lines we need to create them first. - for (0..blank_lines) |i| { - // If we're at the bottom we can't fit anymore into this page, - // so we need to reloop and create a new page. - if (dst_cursor.bottom()) { - blank_lines -= i; - continue :dst_loop; - } + if (blank_lines > 0) { + // This is a dumb edge caes where if we start with blank + // lines, we're off by one because our cursor is at 0 + // on the first blank line but if its in the middle we + // haven't scrolled yet. Don't worry, this is covered by + // unit tests so if we find a better way we can remove this. + const len = blank_lines - @intFromBool(blank_lines >= src_y); + for (0..len) |i| { + // If we're at the bottom we can't fit anymore into this page, + // so we need to reloop and create a new page. + if (dst_cursor.bottom()) { + blank_lines -= i; + continue :dst_loop; + } - // TODO: cursor in here - dst_cursor.cursorScroll(); + // TODO: cursor in here + dst_cursor.cursorScroll(); + } } if (src_y > 0) { @@ -891,7 +899,7 @@ fn reflowPage( // // The blank_lines == 0 condition is because if we were prefixed // with blank lines, we handled the scroll already above. - if (!prev_wrap and blank_lines == 0) { + if (!prev_wrap) { if (dst_cursor.bottom()) continue :dst_loop; dst_cursor.cursorScroll(); } @@ -905,7 +913,7 @@ fn reflowPage( for (src_cursor.x..cols_len) |src_x| { assert(src_cursor.x == src_x); - // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={u} wide={}", .{ + // std.log.warn("src_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{ // src_cursor.y, // src_cursor.x, // dst_cursor.y, @@ -4806,6 +4814,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; + std.log.warn("GO", .{}); var s = try init(alloc, 4, 4, 0); defer s.deinit(); { @@ -4831,14 +4840,15 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; { - const rac = page.getRowAndCell(0, 1); + const p = s.pin(.{ .active = .{ .y = 1 } }).?; + const rac = p.rowAndCell(); try testing.expect(rac.row.wrap); try testing.expect(rac.row.semantic_prompt == .prompt); } { - const rac = page.getRowAndCell(0, 2); + const p = s.pin(.{ .active = .{ .y = 2 } }).?; + const rac = p.rowAndCell(); try testing.expect(rac.row.semantic_prompt == .prompt); } } @@ -5457,7 +5467,7 @@ test "PageList resize reflow less cols blank lines between" { // Resize try s.resize(.{ .cols = 2, .reflow = true }); try testing.expectEqual(@as(usize, 2), s.cols); - try testing.expectEqual(@as(usize, 4), s.totalRows()); + try testing.expectEqual(@as(usize, 5), s.totalRows()); var it = s.rowIterator(.right_down, .{ .active = .{} }, null); { @@ -5483,6 +5493,59 @@ test "PageList resize reflow less cols blank lines between" { } } +test "PageList resize reflow less cols blank lines between no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + { + const rac = page.getRowAndCell(0, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'C' }, + }; + } + + // Resize + try s.resize(.{ .cols = 2, .reflow = true }); + try testing.expectEqual(@as(usize, 2), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + } + { + const offset = it.next().?; + const rac = offset.rowAndCell(); + const cells = offset.page.data.getCells(rac.row); + try testing.expect(!rac.row.wrap); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); + } +} + test "PageList resize reflow less cols cursor not on last line preserves location" { const testing = std.testing; const alloc = testing.allocator; From 8ccc30da109981c324ed4800ba3b5961c9d5385d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 16:57:41 -0700 Subject: [PATCH 264/428] core: surface now tracks left click pin --- src/Surface.zig | 63 +++++++++++++++++++++++++++++-------------- src/terminal/main.zig | 1 + 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ccfbd732cc..20368bd7ba 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -157,7 +157,8 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. //TODO(paged-terminal) - //left_click_point: terminal.point.ScreenPoint = .{}, + left_click_pin: ?*terminal.Pin = null, + left_click_screen: terminal.ScreenType = .primary, /// 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 @@ -1049,9 +1050,9 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) /// Set the selection contents. /// /// This must be called with the renderer mutex held. -fn setSelection(self: *Surface, sel_: ?terminal.Selection) void { +fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { const prev_ = self.io.terminal.screen.selection; - self.io.terminal.screen.selection = sel_; + try self.io.terminal.screen.select(sel_); // Determine the clipboard we want to copy selection to, if it is enabled. const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { @@ -1541,7 +1542,7 @@ pub fn keyCallback( if (!event.key.modifier()) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.setSelection(null); + try self.setSelection(null); try self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } @@ -1748,7 +1749,7 @@ pub fn scrollCallback( // The selection can occur if the user uses the shift mod key to // override mouse grabbing from the window. if (self.io.terminal.flags.mouse_event != .none) { - self.setSelection(null); + try self.setSelection(null); } // If we're in alternate screen with alternate scroll enabled, then @@ -1762,7 +1763,7 @@ pub fn scrollCallback( if (y.delta_unsigned > 0) { // When we send mouse events as cursor keys we always // clear the selection. - self.setSelection(null); + try self.setSelection(null); const seq = if (self.io.terminal.modes.get(.cursor_keys)) seq: { // cursor key: application mode @@ -2219,7 +2220,7 @@ pub fn mouseButtonCallback( // In any other mouse button scenario without shift pressed we // clear the selection since the underlying application can handle // that in any way (i.e. "scrolling"). - self.setSelection(null); + try self.setSelection(null); // We also set the left click count to 0 so that if mouse reporting // is disabled in the middle of press (before release) we don't @@ -2261,15 +2262,31 @@ pub fn mouseButtonCallback( // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) click: { - // TODO(paged-terminal) - if (true) break :click; - self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + const screen = &self.renderer_state.terminal.screen; const pos = try self.rt_surface.getCursorPos(); - const pt_viewport = self.posToViewport(pos.x, pos.y); - const pt_screen = pt_viewport.toScreen(&self.io.terminal.screen); + const pin = pin: { + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + // Weird... our viewport x/y that we just converted isn't + // found in our pages. This is probably a bug but we don't + // want to crash in releases because its harmless. So, we + // only assert in debug mode. + if (comptime std.debug.runtime_safety) unreachable; + break :click; + }; + + break :pin try screen.pages.trackPin(pin); + }; + errdefer screen.pages.untrackPin(pin); // If we move our cursor too much between clicks then we reset // the multi-click state. @@ -2283,8 +2300,14 @@ pub fn mouseButtonCallback( if (distance > max_distance) self.mouse.left_click_count = 0; } + // TODO(paged-terminal): untrack previous pin across screens + if (self.mouse.left_click_pin) |prev| { + screen.pages.untrackPin(prev); + } + // Store it - self.mouse.left_click_point = pt_screen; + self.mouse.left_click_pin = pin; + self.mouse.left_click_screen = t.active_screen; self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -2314,16 +2337,16 @@ pub fn mouseButtonCallback( 1 => { // If we have a selection, clear it. This always happens. if (self.io.terminal.screen.selection != null) { - self.setSelection(null); + try self.setSelection(null); try self.queueRender(); } }, // Double click, select the word under our mouse 2 => { - const sel_ = self.io.terminal.screen.selectWord(self.mouse.left_click_point); + const sel_ = self.io.terminal.screen.selectWord(pin.*); if (sel_) |sel| { - self.setSelection(sel); + try self.setSelection(sel); try self.queueRender(); } }, @@ -2331,11 +2354,11 @@ pub fn mouseButtonCallback( // Triple click, select the line under our mouse 3 => { const sel_ = if (mods.ctrl) - self.io.terminal.screen.selectOutput(self.mouse.left_click_point) + self.io.terminal.screen.selectOutput(pin.*) else - self.io.terminal.screen.selectLine(self.mouse.left_click_point); + self.io.terminal.screen.selectLine(pin.*); if (sel_) |sel| { - self.setSelection(sel); + try self.setSelection(sel); try self.queueRender(); } }, @@ -3304,7 +3327,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { - self.setSelection(s); + try self.setSelection(s); try self.queueRender(); } }, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 1d96871749..c0863f1f4f 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -32,6 +32,7 @@ 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 ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; From 75255780e949b6d02165a67672b27c6f3da6a0cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 17:04:18 -0700 Subject: [PATCH 265/428] renderer/metal: show selections --- src/renderer/Metal.zig | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index d0ff577ad3..1b801403b0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1694,6 +1694,7 @@ fn rebuildCells( }; if (self.updateCell( + screen, cell, color_palette, shaper_cell, @@ -1760,6 +1761,7 @@ fn rebuildCells( fn updateCell( self: *Metal, + screen: *const terminal.Screen, cell_pin: terminal.Pin, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, @@ -1780,18 +1782,10 @@ fn updateCell( }; // True if this cell is selected - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - const selected = false; - // TODO(paged-terminal) - // const selected: bool = if (screen.selection) |sel| selected: { - // const screen_point = (terminal.point.Viewport{ - // .x = x, - // .y = y, - // }).toScreen(screen); - // - // break :selected sel.contains(screen_point); - // } else false; + const selected: bool = if (screen.selection) |sel| + sel.contains(screen, cell_pin) + else + false; const rac = cell_pin.rowAndCell(); const cell = rac.cell; From 4254dc9eeffaccd04b21daae411dc9b72b9c7deb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 17:26:32 -0700 Subject: [PATCH 266/428] core: single click selection is on the way --- src/Surface.zig | 123 ++++++++++++++++++++------------------ src/terminal/PageList.zig | 16 +++++ 2 files changed, 81 insertions(+), 58 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 20368bd7ba..039606e7e1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1054,6 +1054,9 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { const prev_ = self.io.terminal.screen.selection; try self.io.terminal.screen.select(sel_); + // TODO(paged-terminal) + if (true) return; + // Determine the clipboard we want to copy selection to, if it is enabled. const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { .false => return, @@ -2623,16 +2626,25 @@ pub fn cursorPosCallback( // Convert to points // TODO(paged-terminal) - // const screen_point = pos_vp.toScreen(&self.io.terminal.screen); - // - // // Handle dragging depending on click count - // switch (self.mouse.left_click_count) { - // 1 => self.dragLeftClickSingle(screen_point, pos.x), - // 2 => self.dragLeftClickDouble(screen_point), - // 3 => self.dragLeftClickTriple(screen_point), - // 0 => unreachable, // handled above - // else => unreachable, - // } + const screen = &self.renderer_state.terminal.screen; + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + + // Handle dragging depending on click count + switch (self.mouse.left_click_count) { + 1 => try self.dragLeftClickSingle(pin, pos.x), + // 2 => self.dragLeftClickDouble(screen_point), + // 3 => self.dragLeftClickTriple(screen_point), + // 0 => unreachable, // handled above + else => unreachable, + } return; } @@ -2730,9 +2742,9 @@ fn dragLeftClickTriple( fn dragLeftClickSingle( self: *Surface, - screen_point: terminal.point.ScreenPoint, + drag_pin: terminal.Pin, xpos: f64, -) void { +) !void { // NOTE(mitchellh): This logic super sucks. There has to be an easier way // to calculate this, but this is good for a v1. Selection isn't THAT // common so its not like this performance heavy code is running that @@ -2742,7 +2754,8 @@ fn dragLeftClickSingle( // If we were selecting, and we switched directions, then we restart // calculations because it forces us to reconsider if the first cell is // selected. - self.checkResetSelSwitch(screen_point); + // TODO(paged-terminal) + //self.checkResetSelSwitch(screen_point); // Our logic for determining if the starting cell is selected: // @@ -2756,19 +2769,22 @@ fn dragLeftClickSingle( // - Inverted logic for forwards selections. // + // Our clicking point + const click_pin = self.mouse.left_click_pin.?.*; + // the boundary point at which we consider selection or non-selection const cell_width_f64: f64 = @floatFromInt(self.cell_size.width); const cell_xboundary = cell_width_f64 * 0.6; // first xpos of the clicked cell adjusted for padding const left_padding_f64: f64 = @as(f64, @floatFromInt(self.padding.left)); - const cell_xstart = @as(f64, @floatFromInt(self.mouse.left_click_point.x)) * cell_width_f64; + const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64; const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64; // If this is the same cell, then we only start the selection if weve // moved past the boundary point the opposite direction from where we // started. - if (std.meta.eql(screen_point, self.mouse.left_click_point)) { + if (click_pin.eql(drag_pin)) { // Ensuring to adjusting the cursor position for padding const cell_xpos = xpos - cell_xstart - left_padding_f64; const selected: bool = if (cell_start_xpos < cell_xboundary) @@ -2776,11 +2792,11 @@ fn dragLeftClickSingle( else cell_xpos < cell_xboundary; - self.setSelection(if (selected) .{ - .start = screen_point, - .end = screen_point, - .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, - } else null); + try self.setSelection(if (selected) terminal.Selection.init( + drag_pin, + drag_pin, + self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + ) else null); return; } @@ -2792,42 +2808,30 @@ fn dragLeftClickSingle( // the starting cell if we started after the boundary, else // we start selection of the prior cell. // - Inverse logic for a point after the start. - const click_point = self.mouse.left_click_point; - const start: terminal.point.ScreenPoint = if (dragLeftClickBefore( - screen_point, - click_point, + const start: terminal.Pin = if (dragLeftClickBefore( + drag_pin, + click_pin, self.mouse.mods, )) start: { - if (cell_start_xpos >= cell_xboundary) { - break :start click_point; - } else { - break :start if (click_point.x > 0) terminal.point.ScreenPoint{ - .y = click_point.y, - .x = click_point.x - 1, - } else terminal.point.ScreenPoint{ - .x = self.io.terminal.screen.cols - 1, - .y = click_point.y -| 1, - }; - } + if (cell_start_xpos >= cell_xboundary) break :start click_pin; + if (click_pin.x > 0) break :start click_pin.left(1); + var start = click_pin.up(1) orelse click_pin; + start.x = self.io.terminal.screen.pages.cols - 1; + break :start start; } else start: { - if (cell_start_xpos < cell_xboundary) { - break :start click_point; - } else { - break :start if (click_point.x < self.io.terminal.screen.cols - 1) terminal.point.ScreenPoint{ - .y = click_point.y, - .x = click_point.x + 1, - } else terminal.point.ScreenPoint{ - .y = click_point.y + 1, - .x = 0, - }; - } + if (cell_start_xpos < cell_xboundary) break :start click_pin; + if (click_pin.x < self.io.terminal.screen.pages.cols - 1) + break :start click_pin.right(1); + var start = click_pin.down(1) orelse click_pin; + start.x = 0; + break :start start; }; - self.setSelection(.{ - .start = start, - .end = screen_point, - .rectangle = self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, - }); + try self.setSelection(terminal.Selection.init( + start, + drag_pin, + self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + )); return; } @@ -2837,9 +2841,12 @@ fn dragLeftClickSingle( // We moved! Set the selection end point. The start point should be // set earlier. assert(self.io.terminal.screen.selection != null); - var sel = self.io.terminal.screen.selection.?; - sel.end = screen_point; - self.setSelection(sel); + const sel = self.io.terminal.screen.selection.?; + try self.setSelection(terminal.Selection.init( + sel.start(), + drag_pin, + sel.rectangle, + )); } // Resets the selection if we switched directions, depending on the select @@ -2880,15 +2887,15 @@ fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) // where to start the selection (before or after the click point). See // dragLeftClickSingle for more details. fn dragLeftClickBefore( - screen_point: terminal.point.ScreenPoint, - click_point: terminal.point.ScreenPoint, + drag_pin: terminal.Pin, + click_pin: terminal.Pin, mods: input.Mods, ) bool { if (mods.ctrlOrSuper() and mods.alt) { - return screen_point.x < click_point.x; + return drag_pin.x < click_pin.x; } - return screen_point.before(click_point); + return drag_pin.before(click_pin); } /// Call to notify Ghostty that the color scheme for the terminal has diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 64034e9a41..21efa61ef1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2394,6 +2394,22 @@ pub const Pin = struct { self.x == other.x; } + /// Move the pin left n columns. n must fit within the size. + pub fn left(self: Pin, n: usize) Pin { + assert(n <= self.x); + var result = self; + result.x -= n; + return result; + } + + /// Move the pin right n columns. n must fit within the size. + pub fn right(self: Pin, n: usize) Pin { + assert(self.x + n < self.page.data.size.cols); + var result = self; + result.x += n; + return result; + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { From 361fdd2179e53b2d7fa54bba3046504e4227b93f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 20:57:44 -0700 Subject: [PATCH 267/428] core: checkResetSelSwitch converted --- src/Surface.zig | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 039606e7e1..6dbb27e617 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2754,8 +2754,7 @@ fn dragLeftClickSingle( // If we were selecting, and we switched directions, then we restart // calculations because it forces us to reconsider if the first cell is // selected. - // TODO(paged-terminal) - //self.checkResetSelSwitch(screen_point); + self.checkResetSelSwitch(drag_pin); // Our logic for determining if the starting cell is selected: // @@ -2851,8 +2850,14 @@ fn dragLeftClickSingle( // Resets the selection if we switched directions, depending on the select // mode. See dragLeftClickSingle for more details. -fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) void { - const sel = self.io.terminal.screen.selection orelse return; +fn checkResetSelSwitch( + self: *Surface, + drag_pin: terminal.Pin, +) void { + const screen = &self.io.terminal.screen; + const sel = screen.selection orelse return; + const sel_start = sel.start(); + const sel_end = sel.end(); var reset: bool = false; if (sel.rectangle) { @@ -2860,26 +2865,27 @@ fn checkResetSelSwitch(self: *Surface, screen_point: terminal.point.ScreenPoint) // the click point depending on the selection mode we're in, with // the exception of single-column selections, which we always reset // on if we drift. - if (sel.start.x == sel.end.x) { - reset = screen_point.x != sel.start.x; + if (sel_start.x == sel_end.x) { + reset = drag_pin.x != sel_start.x; } else { - reset = switch (sel.order()) { - .forward => screen_point.x < sel.start.x or screen_point.y < sel.start.y, - .reverse => screen_point.x > sel.start.x or screen_point.y > sel.start.y, - .mirrored_forward => screen_point.x > sel.start.x or screen_point.y < sel.start.y, - .mirrored_reverse => screen_point.x < sel.start.x or screen_point.y > sel.start.y, + reset = switch (sel.order(screen)) { + .forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start), + .reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin), + .mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start), + .mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin), }; } } else { // Normal select uses simpler logic that is just based on the // selection start/end. - reset = if (sel.end.before(sel.start)) - sel.start.before(screen_point) + reset = if (sel_end.before(sel_start)) + sel_start.before(drag_pin) else - screen_point.before(sel.start); + drag_pin.before(sel_start); } - if (reset) self.setSelection(null); + // Nullifying a selection can't fail. + if (reset) self.setSelection(null) catch unreachable; } // Handles how whether or not the drag screen point is before the click point. From 4d0f21002565ebf4149eb5c0c1ccb9464cb0f419 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 21:03:16 -0700 Subject: [PATCH 268/428] core: double-click drag --- src/Surface.zig | 44 +++++++++++++++++++++-------------------- src/terminal/Screen.zig | 26 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6dbb27e617..37172b51cf 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2640,7 +2640,7 @@ pub fn cursorPosCallback( // Handle dragging depending on click count switch (self.mouse.left_click_count) { 1 => try self.dragLeftClickSingle(pin, pos.x), - // 2 => self.dragLeftClickDouble(screen_point), + 2 => try self.dragLeftClickDouble(pin), // 3 => self.dragLeftClickTriple(screen_point), // 0 => unreachable, // handled above else => unreachable, @@ -2680,38 +2680,40 @@ pub fn cursorPosCallback( /// Double-click dragging moves the selection one "word" at a time. fn dragLeftClickDouble( self: *Surface, - screen_point: terminal.point.ScreenPoint, -) void { + drag_pin: terminal.Pin, +) !void { + const screen = &self.io.terminal.screen; + const click_pin = self.mouse.left_click_pin.?.*; + // Get the word closest to our starting click. - const word_start = self.io.terminal.screen.selectWordBetween( - self.mouse.left_click_point, - screen_point, - ) orelse { - self.setSelection(null); + const word_start = screen.selectWordBetween(click_pin, drag_pin) orelse { + try self.setSelection(null); return; }; // Get the word closest to our current point. - const word_current = self.io.terminal.screen.selectWordBetween( - screen_point, - self.mouse.left_click_point, + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, ) orelse { - self.setSelection(null); + try self.setSelection(null); return; }; // If our current mouse position is before the starting position, // then the seletion start is the word nearest our current position. - if (screen_point.before(self.mouse.left_click_point)) { - self.setSelection(.{ - .start = word_current.start, - .end = word_start.end, - }); + if (drag_pin.before(click_pin)) { + try self.setSelection(terminal.Selection.init( + word_current.start(), + word_start.end(), + false, + )); } else { - self.setSelection(.{ - .start = word_start.start, - .end = word_current.end, - }); + try self.setSelection(terminal.Selection.init( + word_start.start(), + word_current.end(), + false, + )); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c5e7cfefb9..9cfc974013 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1295,6 +1295,32 @@ pub fn selectAll(self: *Screen) ?Selection { 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. +/// +/// TODO: test this +pub fn selectWordBetween( + self: *Screen, + start: Pin, + end: Pin, +) ?Selection { + const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; + var it = start.cellIterator(dir, end); + while (it.next()) |pin| { + // Boundary conditions + switch (dir) { + .right_down => if (end.before(pin)) return null, + .left_up => if (pin.before(end)) return null, + } + + // If we found a word, then return it + if (self.selectWord(pin)) |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. From edc0864f325769a4e070ea2bd07a7045d2d246a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 21:05:03 -0700 Subject: [PATCH 269/428] core: drag triple click --- src/Surface.zig | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 37172b51cf..d7f6cfd530 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2641,8 +2641,8 @@ pub fn cursorPosCallback( switch (self.mouse.left_click_count) { 1 => try self.dragLeftClickSingle(pin, pos.x), 2 => try self.dragLeftClickDouble(pin), - // 3 => self.dragLeftClickTriple(screen_point), - // 0 => unreachable, // handled above + 3 => try self.dragLeftClickTriple(pin), + 0 => unreachable, // handled above else => unreachable, } @@ -2720,26 +2720,29 @@ fn dragLeftClickDouble( /// Triple-click dragging moves the selection one "line" at a time. fn dragLeftClickTriple( self: *Surface, - screen_point: terminal.point.ScreenPoint, -) void { + drag_pin: terminal.Pin, +) !void { + const screen = &self.io.terminal.screen; + const click_pin = self.mouse.left_click_pin.?.*; + // Get the word under our current point. If there isn't a word, do nothing. - const word = self.io.terminal.screen.selectLine(screen_point) orelse return; + const word = screen.selectLine(drag_pin) orelse return; // Get our selection to grow it. If we don't have a selection, start it now. // We may not have a selection if we started our dbl-click in an area // that had no data, then we dragged our mouse into an area with data. - var sel = self.io.terminal.screen.selectLine(self.mouse.left_click_point) orelse { - self.setSelection(word); + var sel = screen.selectLine(click_pin) orelse { + try self.setSelection(word); return; }; // Grow our selection - if (screen_point.before(self.mouse.left_click_point)) { - sel.start = word.start; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = word.start(); } else { - sel.end = word.end; + sel.endPtr().* = word.end(); } - self.setSelection(sel); + try self.setSelection(sel); } fn dragLeftClickSingle( From 6de661b9d1063b8a25a5693083d8083c8f8d4649 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 21:05:57 -0700 Subject: [PATCH 270/428] core: remove completed todos --- src/Surface.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index d7f6cfd530..aaae855c19 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -156,7 +156,6 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. - //TODO(paged-terminal) left_click_pin: ?*terminal.Pin = null, left_click_screen: terminal.ScreenType = .primary, @@ -1054,9 +1053,6 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { const prev_ = self.io.terminal.screen.selection; try self.io.terminal.screen.select(sel_); - // TODO(paged-terminal) - if (true) return; - // Determine the clipboard we want to copy selection to, if it is enabled. const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { .false => return, @@ -2625,7 +2621,6 @@ pub fn cursorPosCallback( } // Convert to points - // TODO(paged-terminal) const screen = &self.renderer_state.terminal.screen; const pin = screen.pages.pin(.{ .viewport = .{ From bb42adeb2d90b05bb320f7c070f9940f33de631e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Mar 2024 21:11:25 -0700 Subject: [PATCH 271/428] update TODO.md --- TODO.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 893233dea2..6ba65c1a24 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,6 @@ Performance: -- for scrollback, investigate using segmented list for sufficiently large - scrollback scenarios. - Loading fonts on startups should probably happen in multiple threads -- `deleteLines` is very, very slow which makes scroll region benchmarks terrible Correctness: @@ -15,10 +12,6 @@ Correctness: - can effect a crash using `vttest` menu `3 10` since it tries to parse ASCII as UTF-8. -Improvements: - -- scrollback: configurable - Mac: - Preferences window @@ -27,3 +20,10 @@ Major Features: - Bell - Sixels: https://saitoha.github.io/libsixel/ + +paged-terminal branch: + +- tests and logic for overflowing page capacities: + * graphemes + * styles +- configurable scrollback size From 7ff5577d05140b2f5fbd2fadcc5e294a708ba7f4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 17:08:23 -0700 Subject: [PATCH 272/428] terminal: PageSize adjustCapacity for non-standard pages --- src/terminal/PageList.zig | 160 +++++++++++++++++++++++++++++++++----- 1 file changed, 142 insertions(+), 18 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 21efa61ef1..b0dd740a71 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -34,12 +34,13 @@ const List = std.DoublyLinkedList(Page); const NodePool = std.heap.MemoryPool(List.Node); const std_capacity = pagepkg.std_capacity; +const std_size = Page.layout(std_capacity).total_size; /// The memory pool we use for page memory buffers. We use a separate pool /// so we can allocate these with a page allocator. We have to use a page /// allocator because we need memory that is zero-initialized and page-aligned. const PagePool = std.heap.MemoryPoolAligned( - [Page.layout(std_capacity).total_size]u8, + [std_size]u8, std.mem.page_size, ); @@ -234,6 +235,16 @@ pub fn deinit(self: *PageList) void { // Always deallocate our hashmap. self.tracked_pins.deinit(self.pool.alloc); + // Go through our linked list and deallocate all pages that are + // not standard size. + const page_alloc = self.pool.pages.arena.child_allocator; + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. if (self.pool_owned) { @@ -1500,28 +1511,95 @@ pub fn grow(self: *PageList) !?*List.Node { return next_page; } +/// Adjust the capacity of the given page in the list. +pub const AdjustCapacity = struct { + /// Adjust the number of styles in the page. This may be + /// rounded up if necessary to fit alignment requirements, + /// but it will never be rounded down. + styles: ?u16 = null, +}; + +/// Adjust the capcaity of the given page in the list. This should +/// be used in cases where OutOfMemory is returned by some operation +/// i.e to increase style counts, grapheme counts, etc. +/// +/// Adjustment works by increasing the capacity of the desired +/// dimension to a certain amount and increases the memory allocation +/// requirement for the backing memory of the page. We currently +/// never split pages or anything like that. Because increased allocation +/// has to happen outside our memory pool, its generally much slower +/// so pages should be sized to be large enough to handle all but +/// exceptional cases. +/// +/// This can currently only INCREASE capacity size. It cannot +/// decrease capacity size. This limitation is only because we haven't +/// yet needed that use case. If we ever do, this can be added. Currently +/// any requests to decrease will be ignored. +pub fn adjustCapacity( + self: *PageList, + page: *List.Node, + adjustment: AdjustCapacity, +) !void { + // We always use our base capacity which is our standard + // adjusted for our column size. + var cap = try std_capacity.adjust(.{ .cols = self.cols }); + + // From there, we increase our capacity as required + if (adjustment.styles) |v| { + const aligned = try std.math.ceilPowerOfTwo(u16, v); + cap.styles = @max(cap.styles, aligned); + } + + // Create our new page and clone the old page into it. + const new_page = try self.createPage(cap); + errdefer self.destroyPage(new_page); + assert(new_page.data.capacity.rows >= page.data.capacity.rows); + new_page.data.size.rows = page.data.size.rows; + try new_page.data.cloneFrom(&page.data, 0, page.data.size.rows); + + // Insert this page and destroy the old page + self.pages.insertBefore(page, new_page); + self.pages.remove(page); + self.destroyPage(page); +} + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. fn createPage(self: *PageList, cap: Capacity) !*List.Node { var page = try self.pool.nodes.create(); errdefer self.pool.nodes.destroy(page); - const page_buf = try self.pool.pages.create(); - errdefer self.pool.pages.destroy(page_buf); + const layout = Page.layout(cap); + const pooled = layout.total_size <= std_size; + const page_alloc = self.pool.pages.arena.child_allocator; + + // Our page buffer comes from our standard memory pool if it + // is within our standard size since this is what the pool + // dispenses. Otherwise, we use the heap allocator to allocate. + const page_buf = if (pooled) + try self.pool.pages.create() + else + try page_alloc.alignedAlloc( + u8, + std.mem.page_size, + layout.total_size, + ); + errdefer if (pooled) + self.pool.pages.destroy(page_buf) + else + page_alloc.free(page_buf); + + // Required only with runtime safety because allocators initialize + // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ - .data = Page.initBuf( - OffsetBuf.init(page_buf), - Page.layout(cap), - ), - }; + page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; page.data.size.rows = 0; // Accumulate page size now. We don't assert or check max size because // we may exceed it here temporarily as we are allocating pages before // destroy. - self.page_size += PagePool.item_size; + self.page_size += page_buf.len; return page; } @@ -1529,15 +1607,19 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { /// Destroy the memory of the given page and return it to the pool. The /// page is assumed to already be removed from the linked list. fn destroyPage(self: *PageList, page: *List.Node) void { - // Reset the memory to zero so it can be reused - @memset(page.data.memory, 0); + // Update our accounting for page size + self.page_size -= page.data.memory.len; - // Put it back into the allocator pool - self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); - self.pool.nodes.destroy(page); + if (page.data.memory.len <= std_size) { + // Reset the memory to zero so it can be reused + @memset(page.data.memory, 0); + self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); + } else { + const page_alloc = self.pool.pages.arena.child_allocator; + page_alloc.free(page.data.memory); + } - // Update our accounting for page size - self.page_size -= PagePool.item_size; + self.pool.nodes.destroy(page); } /// Erase the rows from the given top to bottom (inclusive). Erasing @@ -3000,6 +3082,49 @@ test "PageList grow prune scrollback" { try testing.expectEqual(page1_node, s.pages.last.?); } +test "PageList adjustCapacity to increase styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our styles + try s.adjustCapacity( + s.pages.first.?, + .{ .styles = std_capacity.styles * 2 }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; @@ -4830,7 +4955,6 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; - std.log.warn("GO", .{}); var s = try init(alloc, 4, 4, 0); defer s.deinit(); { From 98b16930c3cda8ca1a26006f7eb9538848efaf8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 19:08:55 -0700 Subject: [PATCH 273/428] terminal: PageList adjustCapacity should return new node and fix pins --- src/terminal/PageList.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b0dd740a71..5161224f8e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1539,7 +1539,7 @@ pub fn adjustCapacity( self: *PageList, page: *List.Node, adjustment: AdjustCapacity, -) !void { +) !*List.Node { // We always use our base capacity which is our standard // adjusted for our column size. var cap = try std_capacity.adjust(.{ .cols = self.cols }); @@ -1557,10 +1557,20 @@ pub fn adjustCapacity( new_page.data.size.rows = page.data.size.rows; try new_page.data.cloneFrom(&page.data, 0, page.data.size.rows); + // Fix up all our tracked pins to point to the new page. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + p.page = new_page; + } + // Insert this page and destroy the old page self.pages.insertBefore(page, new_page); self.pages.remove(page); self.destroyPage(page); + + return new_page; } /// Create a new page node. This does not add it to the list and this @@ -3105,7 +3115,7 @@ test "PageList adjustCapacity to increase styles" { } // Increase our styles - try s.adjustCapacity( + _ = try s.adjustCapacity( s.pages.first.?, .{ .styles = std_capacity.styles * 2 }, ); From 5e68bc60e03252846bc52c674bf6a64829847f3b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 19:18:27 -0700 Subject: [PATCH 274/428] terminal: resize page on unique style per cell --- src/terminal/Screen.zig | 35 ++++++++++++++++++++++++++++++++--- src/terminal/Terminal.zig | 18 ++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9cfc974013..7773f50819 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -646,9 +646,15 @@ pub fn clearCells( // 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| { + 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; + 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); @@ -962,7 +968,30 @@ pub fn manualStyleUpdate(self: *Screen) !void { // 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); + const md = page.styles.upsert( + page.memory, + self.cursor.style, + ) catch |err| switch (err) { + // Our style map is full. Let's allocate a new page by doubling + // the size and then try again. + error.OutOfMemory => md: { + const node = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .styles = page.capacity.styles * 2 }, + ); + + // Since this modifies our cursor page, we need to reload + self.cursorReload(); + + page = &node.data; + break :md try page.styles.upsert( + page.memory, + self.cursor.style, + ); + }, + + error.Overflow => return err, // TODO + }; self.cursor.style_id = md.id; self.cursor.style_ref = &md.ref; } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a0224e195b..dae0fbe925 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2264,6 +2264,24 @@ test "Terminal: input that forces scroll" { } } +test "Terminal: input unique style per cell" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 30); + defer t.deinit(alloc); + + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } + } +} + test "Terminal: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); From dc04cc1317df1ca20f942b20cad17dbf2d981577 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 19:23:27 -0700 Subject: [PATCH 275/428] terminal: handle style ID overflow --- src/terminal/PageList.zig | 12 +++++++++++ src/terminal/Screen.zig | 44 ++++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5161224f8e..f18c9d143d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1573,6 +1573,18 @@ pub fn adjustCapacity( return new_page; } +/// Compact a page, reallocating to minimize the amount of memory +/// required for the page. This is useful when we've overflowed ID +/// spaces, are archiving a page, etc. +/// +/// Note today: this doesn't minimize the memory usage, but it does +/// fix style ID overflow. A future update can shrink the memory +/// allocation. +pub fn compact(self: *PageList, page: *List.Node) !*List.Node { + // Adjusting capacity with no adjustments forces a reallocation. + return try self.adjustCapacity(page, .{}); +} + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. fn createPage(self: *PageList, cap: Capacity) !*List.Node { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 7773f50819..de75173079 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -971,26 +971,36 @@ pub fn manualStyleUpdate(self: *Screen) !void { const md = page.styles.upsert( page.memory, self.cursor.style, - ) catch |err| switch (err) { - // Our style map is full. Let's allocate a new page by doubling - // the size and then try again. - error.OutOfMemory => md: { - const node = try self.pages.adjustCapacity( - self.cursor.page_pin.page, - .{ .styles = page.capacity.styles * 2 }, - ); + ) catch |err| md: { + switch (err) { + // Our style map is full. Let's allocate a new page by doubling + // the size and then try again. + error.OutOfMemory => { + const node = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .styles = page.capacity.styles * 2 }, + ); + + page = &node.data; + }, - // Since this modifies our cursor page, we need to reload - self.cursorReload(); + // We've run out of style IDs. This is fixed by doing a page + // compaction. + error.Overflow => { + const node = try self.pages.compact( + self.cursor.page_pin.page, + ); + page = &node.data; + }, + } - page = &node.data; - break :md try page.styles.upsert( - page.memory, - self.cursor.style, - ); - }, + // Since this modifies our cursor page, we need to reload + self.cursorReload(); - error.Overflow => return err, // TODO + break :md try page.styles.upsert( + page.memory, + self.cursor.style, + ); }; self.cursor.style_id = md.id; self.cursor.style_ref = &md.ref; From ab1a302daa3131223cd179a7067b0bf05fe8113e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 19:37:12 -0700 Subject: [PATCH 276/428] terminal: PageList.clone must use createPageExt for non-std pages --- src/terminal/PageList.zig | 86 +++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f18c9d143d..ebf1c7bec5 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -328,17 +328,38 @@ pub fn clone( errdefer tracked_pins.deinit(pool.alloc); try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); - // Copy our pages + // Our list of pages var page_list: List = .{}; + errdefer { + const page_alloc = pool.pages.arena.child_allocator; + var page_it = page_list.first; + while (page_it) |node| : (page_it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + } + + // Copy our pages var total_rows: usize = 0; - var page_count: usize = 0; + var page_size: usize = 0; while (it.next()) |chunk| { - // Clone the page - const page = try pool.nodes.create(); - const page_buf = try pool.pages.create(); - page.* = .{ .data = chunk.page.data.cloneBuf(page_buf) }; + // Clone the page. We have to use createPageExt here because + // we don't know if the source page has a standard size. + const page = try createPageExt( + pool, + chunk.page.data.capacity, + &page_size, + ); + assert(page.data.capacity.rows >= chunk.page.data.capacity.rows); + page.data.size.rows = chunk.page.data.size.rows; + try page.data.cloneFrom( + &chunk.page.data, + 0, + chunk.page.data.size.rows, + ); + page_list.append(page); - page_count += 1; // If this is a full page then we're done. if (chunk.fullPage()) { @@ -427,7 +448,7 @@ pub fn clone( .alloc => true, }, .pages = page_list, - .page_size = PagePool.item_size * page_count, + .page_size = page_size, .max_size = self.max_size, .cols = self.cols, .rows = self.rows, @@ -1587,19 +1608,30 @@ pub fn compact(self: *PageList, page: *List.Node) !*List.Node { /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. -fn createPage(self: *PageList, cap: Capacity) !*List.Node { - var page = try self.pool.nodes.create(); - errdefer self.pool.nodes.destroy(page); +fn createPage( + self: *PageList, + cap: Capacity, +) !*List.Node { + return try createPageExt(&self.pool, cap, &self.page_size); +} + +fn createPageExt( + pool: *MemoryPool, + cap: Capacity, + total_size: ?*usize, +) !*List.Node { + var page = try pool.nodes.create(); + errdefer pool.nodes.destroy(page); const layout = Page.layout(cap); const pooled = layout.total_size <= std_size; - const page_alloc = self.pool.pages.arena.child_allocator; + const page_alloc = pool.pages.arena.child_allocator; // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. const page_buf = if (pooled) - try self.pool.pages.create() + try pool.pages.create() else try page_alloc.alignedAlloc( u8, @@ -1607,7 +1639,7 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { layout.total_size, ); errdefer if (pooled) - self.pool.pages.destroy(page_buf) + pool.pages.destroy(page_buf) else page_alloc.free(page_buf); @@ -1618,10 +1650,12 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; page.data.size.rows = 0; - // Accumulate page size now. We don't assert or check max size because - // we may exceed it here temporarily as we are allocating pages before - // destroy. - self.page_size += page_buf.len; + if (total_size) |v| { + // Accumulate page size now. We don't assert or check max size + // because we may exceed it here temporarily as we are allocating + // pages before destroy. + v.* += page_buf.len; + } return page; } @@ -1629,19 +1663,27 @@ fn createPage(self: *PageList, cap: Capacity) !*List.Node { /// Destroy the memory of the given page and return it to the pool. The /// page is assumed to already be removed from the linked list. fn destroyPage(self: *PageList, page: *List.Node) void { + destroyPageExt(&self.pool, page, &self.page_size); +} + +fn destroyPageExt( + pool: *MemoryPool, + page: *List.Node, + total_size: ?*usize, +) void { // Update our accounting for page size - self.page_size -= page.data.memory.len; + if (total_size) |v| v.* -= page.data.memory.len; if (page.data.memory.len <= std_size) { // Reset the memory to zero so it can be reused @memset(page.data.memory, 0); - self.pool.pages.destroy(@ptrCast(page.data.memory.ptr)); + pool.pages.destroy(@ptrCast(page.data.memory.ptr)); } else { - const page_alloc = self.pool.pages.arena.child_allocator; + const page_alloc = pool.pages.arena.child_allocator; page_alloc.free(page.data.memory); } - self.pool.nodes.destroy(page); + pool.nodes.destroy(page); } /// Erase the rows from the given top to bottom (inclusive). Erasing From a2e97a86d0155bffa33728290d26de4c8030b8d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 19:58:36 -0700 Subject: [PATCH 277/428] terminal: PageList adjustCap should start from original cap --- src/terminal/PageList.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ebf1c7bec5..e45f1d3286 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1561,9 +1561,9 @@ pub fn adjustCapacity( page: *List.Node, adjustment: AdjustCapacity, ) !*List.Node { - // We always use our base capacity which is our standard - // adjusted for our column size. - var cap = try std_capacity.adjust(.{ .cols = self.cols }); + // We always start with the base capacity of the existing page. This + // ensures we never shrink from what we need. + var cap = page.data.capacity; // From there, we increase our capacity as required if (adjustment.styles) |v| { @@ -1571,6 +1571,8 @@ pub fn adjustCapacity( cap.styles = @max(cap.styles, aligned); } + log.info("adjusting page capacity={}", .{cap}); + // Create our new page and clone the old page into it. const new_page = try self.createPage(cap); errdefer self.destroyPage(new_page); From 9137f52cbfdefc70b64551089e8c1f9abe4d9f01 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 20:13:45 -0700 Subject: [PATCH 278/428] terminal: resize cols without reflow handles higher caps --- src/terminal/PageList.zig | 11 +++++------ src/terminal/Terminal.zig | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e45f1d3286..dcb5402760 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -581,7 +581,7 @@ fn resizeCols( if (row.wrap) break :no_reflow; } - try self.resizeWithoutReflowGrowCols(cap, chunk); + try self.resizeWithoutReflowGrowCols(cols, chunk); continue; } @@ -1245,11 +1245,9 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // pages may not have the capacity for this. If they don't have // the capacity we need to allocate a new page and copy the data. .gt => { - const cap = try std_capacity.adjust(.{ .cols = cols }); - var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - try self.resizeWithoutReflowGrowCols(cap, chunk); + try self.resizeWithoutReflowGrowCols(cols, chunk); } self.cols = cols; @@ -1260,11 +1258,12 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { fn resizeWithoutReflowGrowCols( self: *PageList, - cap: Capacity, + cols: size.CellCountInt, chunk: PageIterator.Chunk, ) !void { - assert(cap.cols > self.cols); + assert(cols > self.cols); const page = &chunk.page.data; + const cap = try page.capacity.adjust(.{ .cols = cols }); // Update our col count const old_cols = self.cols; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index dae0fbe925..75646fc886 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -7708,6 +7708,26 @@ test "Terminal: resize with wraparound on" { try testing.expectEqualStrings("01\n23", str); } +test "Terminal: resize with high unique style per cell" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 30); + defer t.deinit(alloc); + + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } + } + + try t.resize(alloc, 60, 30); +} + test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From 03abde6ba8e32ba7153e92e40f226ca94083034b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 20:42:26 -0700 Subject: [PATCH 279/428] terminal: resize handles increased styles/graphemes --- src/terminal/PageList.zig | 11 ++++++----- src/terminal/Terminal.zig | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index dcb5402760..f1ffd521ed 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -537,9 +537,6 @@ fn resizeCols( ) !void { assert(cols != self.cols); - // Our new capacity, ensure we can fit the cols - const cap = try std_capacity.adjust(.{ .cols = cols }); - // If we have a cursor position (x,y), then we try under any col resizing // to keep the same number remaining active rows beneath it. This is a // very special case if you can imagine clearing the screen (i.e. @@ -588,7 +585,7 @@ fn resizeCols( // Note: we can do a fast-path here if all of our rows in this // page already fit within the new capacity. In that case we can // do a non-reflow resize. - try self.reflowPage(cap, chunk.page); + try self.reflowPage(cols, chunk.page); } // If our total rows is less than our active rows, we need to grow. @@ -774,7 +771,7 @@ const ReflowCursor = struct { /// function. fn reflowPage( self: *PageList, - cap: Capacity, + cols: size.CellCountInt, initial_node: *List.Node, ) !void { // The cursor tracks where we are in the source page. @@ -810,6 +807,10 @@ fn reflowPage( // Our new capacity when growing columns may also shrink rows. So we // need to do a loop in order to potentially make multiple pages. dst_loop: while (true) { + // Our cap is based on the source page cap so we can inherit + // potentially increased styles/graphemes/etc. + const cap = try src_cursor.page.capacity.adjust(.{ .cols = cols }); + // Create our new page and our cursor restarts at 0,0 in the new page. // The new page always starts with a size of 1 because we know we have // at least one row to copy from the src. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 75646fc886..84f0247993 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -7728,6 +7728,27 @@ test "Terminal: resize with high unique style per cell" { try t.resize(alloc, 60, 30); } +test "Terminal: resize with high unique style per cell with wrapping" { + const alloc = testing.allocator; + var t = try init(alloc, 30, 30); + defer t.deinit(alloc); + + const cell_count: u16 = @intCast(t.rows * t.cols); + for (0..cell_count) |i| { + const r: u8 = @intCast(i >> 8); + const g: u8 = @intCast(i & 0xFF); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = r, + .g = g, + .b = 0, + } }); + try t.print('x'); + } + + try t.resize(alloc, 60, 30); +} + test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); From 49e8acbcd2cb5a8f38d309e9b5e0a45e02af867a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 21:02:28 -0700 Subject: [PATCH 280/428] core: configurable scrollback limit --- TODO.md | 2 - src/config/Config.zig | 21 + src/renderer/cursor.zig | 8 +- src/terminal/Terminal.zig | 541 ++++++++++++------------ src/terminal/kitty/graphics_storage.zig | 24 +- src/termio/Exec.zig | 10 +- 6 files changed, 317 insertions(+), 289 deletions(-) diff --git a/TODO.md b/TODO.md index 6ba65c1a24..aef8aa94d6 100644 --- a/TODO.md +++ b/TODO.md @@ -25,5 +25,3 @@ paged-terminal branch: - tests and logic for overflowing page capacities: * graphemes - * styles -- configurable scrollback size diff --git a/src/config/Config.zig b/src/config/Config.zig index eedd6932a3..568d43b2d9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -427,6 +427,27 @@ command: ?[]const u8 = null, /// command. @"abnormal-command-exit-runtime": u32 = 250, +/// The size of the scrollback buffer in bytes. This also includes the active +/// screen. No matter what this is set to, enough memory will always be +/// allocated for the visible screen and anything leftover is the limit for +/// the scrollback. +/// +/// When this limit is reached, the oldest lines are removed from the +/// scrollback. +/// +/// Scrollback currently exists completely in memory. This means that the +/// larger this value, the larger potential memory usage. Scrollback is +/// allocated lazily up to this limit, so if you set this to a very large +/// value, it will not immediately consume a lot of memory. +/// +/// This size is per terminal surface, not for the entire application. +/// +/// It is not currently possible to set an unlimited scrollback buffer. +/// This is a future planned feature. +/// +/// This can be changed at runtime but will only affect new terminal surfaces. +@"scrollback-limit": u32 = 10_000, + /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions /// can be opening using the system opener (i.e. `open` or `xdg-open`) or diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index fd58257bee..d7cb7c5bb0 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -63,7 +63,7 @@ pub fn cursorStyle( test "cursor: default uses configured style" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); term.screen.cursor.cursor_style = .bar; @@ -84,7 +84,7 @@ test "cursor: default uses configured style" { test "cursor: blinking disabled" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); term.screen.cursor.cursor_style = .bar; @@ -105,7 +105,7 @@ test "cursor: blinking disabled" { test "cursor: explictly not visible" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); term.screen.cursor.cursor_style = .bar; @@ -127,7 +127,7 @@ test "cursor: explictly not visible" { test "cursor: always block with preedit" { const testing = std.testing; const alloc = testing.allocator; - var term = try terminal.Terminal.init(alloc, 10, 10); + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); var state: State = .{ diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 84f0247993..f78d5ca97c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -159,15 +159,24 @@ pub const ScrollingRegion = struct { right: size.CellCountInt, }; +pub const Options = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize = 10_000, +}; + /// Initialize a new terminal. -pub fn init(alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt) !Terminal { +pub fn init( + alloc: Allocator, + opts: Options, +) !Terminal { + const cols = opts.cols; + const rows = opts.rows; return Terminal{ .cols = cols, .rows = rows, .active_screen = .primary, - // TODO: configurable scrollback - .screen = try Screen.init(alloc, cols, rows, 10000), - // No scrollback for the alternate screen + .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback), .secondary_screen = try Screen.init(alloc, cols, rows, 0), .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ @@ -2217,7 +2226,7 @@ pub fn fullReset(self: *Terminal) void { test "Terminal: input with no control characters" { const alloc = testing.allocator; - var t = try init(alloc, 40, 40); + var t = try init(alloc, .{ .cols = 40, .rows = 40 }); defer t.deinit(alloc); // Basic grid writing @@ -2233,7 +2242,7 @@ test "Terminal: input with no control characters" { test "Terminal: input with basic wraparound" { const alloc = testing.allocator; - var t = try init(alloc, 5, 40); + var t = try init(alloc, .{ .cols = 5, .rows = 40 }); defer t.deinit(alloc); // Basic grid writing @@ -2250,7 +2259,7 @@ test "Terminal: input with basic wraparound" { test "Terminal: input that forces scroll" { const alloc = testing.allocator; - var t = try init(alloc, 1, 5); + var t = try init(alloc, .{ .cols = 1, .rows = 5 }); defer t.deinit(alloc); // Basic grid writing @@ -2266,7 +2275,7 @@ test "Terminal: input that forces scroll" { test "Terminal: input unique style per cell" { const alloc = testing.allocator; - var t = try init(alloc, 30, 30); + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); for (0..t.rows) |y| { @@ -2283,7 +2292,7 @@ test "Terminal: input unique style per cell" { } test "Terminal: zero-width character at start" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // This used to crash the terminal. This is not allowed so we should @@ -2296,7 +2305,7 @@ test "Terminal: zero-width character at start" { // https://github.com/mitchellh/ghostty/issues/1400 test "Terminal: print single very long line" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // This would crash for issue 1400. So the assertion here is @@ -2305,7 +2314,7 @@ test "Terminal: print single very long line" { } test "Terminal: print wide char" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -2327,14 +2336,14 @@ test "Terminal: print wide char" { test "Terminal: print wide char with 1-column width" { const alloc = testing.allocator; - var t = try init(alloc, 1, 2); + var t = try init(alloc, .{ .cols = 1, .rows = 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); + var t = try init(testing.allocator, .{ .cols = 1, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -2351,7 +2360,7 @@ test "Terminal: print wide char in single-width terminal" { } test "Terminal: print over wide char at 0,0" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -2376,7 +2385,7 @@ test "Terminal: print over wide char at 0,0" { } test "Terminal: print over wide spacer tail" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); try t.print('橋'); @@ -2404,7 +2413,7 @@ test "Terminal: print over wide spacer tail" { } test "Terminal: print multicodepoint grapheme, disabled mode 2027" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // https://github.com/mitchellh/ghostty/issues/289 @@ -2474,7 +2483,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { } test "Terminal: VS16 doesn't make character with 2027 disabled" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Disable grapheme clustering @@ -2501,7 +2510,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // https://github.com/mitchellh/ghostty/issues/1482 @@ -2529,7 +2538,7 @@ test "Terminal: print invalid VS16 non-grapheme" { } test "Terminal: print multicodepoint grapheme, mode 2027" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2568,7 +2577,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2595,7 +2604,7 @@ test "Terminal: VS15 to make narrow character" { } test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2622,7 +2631,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { } test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2660,7 +2669,7 @@ test "Terminal: VS16 repeated with mode 2027" { } test "Terminal: print invalid VS16 grapheme" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2692,7 +2701,7 @@ test "Terminal: print invalid VS16 grapheme" { } test "Terminal: print invalid VS16 with second char" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2727,7 +2736,7 @@ test "Terminal: print invalid VS16 with second char" { } test "Terminal: overwrite grapheme should clear grapheme data" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -2754,7 +2763,7 @@ test "Terminal: overwrite grapheme should clear grapheme data" { } test "Terminal: print writes to bottom if scrolled" { - var t = try init(testing.allocator, 5, 2); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 2 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2791,7 +2800,7 @@ test "Terminal: print writes to bottom if scrolled" { } test "Terminal: print charset" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -2815,7 +2824,7 @@ test "Terminal: print charset" { } test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -2835,7 +2844,7 @@ test "Terminal: print charset outside of ASCII" { } test "Terminal: print invoke charset" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); t.configureCharset(.G1, .dec_special); @@ -2855,7 +2864,7 @@ test "Terminal: print invoke charset" { } test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); t.configureCharset(.G1, .dec_special); @@ -2873,7 +2882,7 @@ test "Terminal: print invoke charset single" { } test "Terminal: soft wrap" { - var t = try init(testing.allocator, 3, 80); + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -2888,7 +2897,7 @@ test "Terminal: soft wrap" { } test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); t.markSemanticPrompt(.prompt); @@ -2905,7 +2914,7 @@ test "Terminal: soft wrap with semantic prompt" { } test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.wraparound, false); @@ -2933,7 +2942,7 @@ test "Terminal: disabled wraparound with wide char and one space" { } test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.wraparound, false); @@ -2960,7 +2969,7 @@ test "Terminal: disabled wraparound with wide char and no space" { } test "Terminal: disabled wraparound with wide grapheme and half space" { - var t = try init(testing.allocator, 5, 5); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.grapheme_cluster, true); @@ -2989,7 +2998,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { } test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -3006,7 +3015,7 @@ test "Terminal: print right margin wrap" { } test "Terminal: print right margin outside" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -3023,7 +3032,7 @@ test "Terminal: print right margin outside" { } test "Terminal: print right margin outside wrap" { - var t = try init(testing.allocator, 10, 5); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -3040,7 +3049,7 @@ test "Terminal: print right margin outside wrap" { } test "Terminal: linefeed and carriage return" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -3058,7 +3067,7 @@ test "Terminal: linefeed and carriage return" { } test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -3069,7 +3078,7 @@ test "Terminal: linefeed unsets pending wrap" { } test "Terminal: linefeed mode automatic carriage return" { - var t = try init(testing.allocator, 10, 10); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -3085,7 +3094,7 @@ test "Terminal: linefeed mode automatic carriage return" { } test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -3096,7 +3105,7 @@ test "Terminal: carriage return unsets pending wrap" { } test "Terminal: carriage return origin mode moves to left margin" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.modes.set(.origin, true); @@ -3107,7 +3116,7 @@ test "Terminal: carriage return origin mode moves to left margin" { } test "Terminal: carriage return left of left margin moves to zero" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screen.cursor.x = 1; @@ -3117,7 +3126,7 @@ test "Terminal: carriage return left of left margin moves to zero" { } test "Terminal: carriage return right of left margin moves to left margin" { - var t = try init(testing.allocator, 5, 80); + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screen.cursor.x = 3; @@ -3127,7 +3136,7 @@ test "Terminal: carriage return right of left margin moves to left margin" { } test "Terminal: backspace" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // BS @@ -3145,7 +3154,7 @@ test "Terminal: backspace" { test "Terminal: horizontal tabs" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // HT @@ -3166,7 +3175,7 @@ test "Terminal: horizontal tabs" { test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(t.screen.cursor.y, 9); @@ -3184,7 +3193,7 @@ test "Terminal: horizontal tabs starting on tabstop" { test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.scrolling_region.left = 2; @@ -3203,7 +3212,7 @@ test "Terminal: horizontal tabs with right margin" { test "Terminal: horizontal tabs back" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // Edge of screen @@ -3226,7 +3235,7 @@ test "Terminal: horizontal tabs back" { test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(t.screen.cursor.y, 9); @@ -3244,7 +3253,7 @@ test "Terminal: horizontal tabs back starting on tabstop" { test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -3264,7 +3273,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" { test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 20, 5); + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -3284,7 +3293,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -3302,7 +3311,7 @@ test "Terminal: cursorPos resets wrap" { test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(500, 500); @@ -3317,7 +3326,7 @@ test "Terminal: cursorPos off the screen" { test "Terminal: cursorPos relative to origin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3335,7 +3344,7 @@ test "Terminal: cursorPos relative to origin" { test "Terminal: cursorPos relative to origin with left/right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3355,7 +3364,7 @@ test "Terminal: cursorPos relative to origin with left/right" { test "Terminal: cursorPos limits with full scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -3375,7 +3384,7 @@ test "Terminal: cursorPos limits with full scroll region" { // Probably outdated, but dates back to the original terminal implementation. test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); @@ -3428,7 +3437,7 @@ test "Terminal: setCursorPos (original test)" { test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3450,7 +3459,7 @@ test "Terminal: setTopAndBottomMargin simple" { test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3472,7 +3481,7 @@ test "Terminal: setTopAndBottomMargin top only" { test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3494,7 +3503,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3516,7 +3525,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3539,7 +3548,7 @@ test "Terminal: setLeftAndRightMargin simple" { test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3565,7 +3574,7 @@ test "Terminal: setLeftAndRightMargin left only" { test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3589,7 +3598,7 @@ test "Terminal: setLeftAndRightMargin left and right" { test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3613,7 +3622,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3637,7 +3646,7 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { test "Terminal: insertLines simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3659,7 +3668,7 @@ test "Terminal: insertLines simple" { test "Terminal: insertLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3697,7 +3706,7 @@ test "Terminal: insertLines colors with bg color" { test "Terminal: insertLines handles style refs" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3731,7 +3740,7 @@ test "Terminal: insertLines handles style refs" { test "Terminal: insertLines outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3754,7 +3763,7 @@ test "Terminal: insertLines outside of scroll region" { test "Terminal: insertLines top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3780,7 +3789,7 @@ test "Terminal: insertLines top/bottom scroll region" { test "Terminal: insertLines (legacy test)" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -3813,7 +3822,7 @@ test "Terminal: insertLines (legacy test)" { test "Terminal: insertLines zero" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // This should do nothing @@ -3823,7 +3832,7 @@ test "Terminal: insertLines zero" { test "Terminal: insertLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 6); + var t = try init(alloc, .{ .cols = 2, .rows = 6 }); defer t.deinit(alloc); // Initial value @@ -3856,7 +3865,7 @@ test "Terminal: insertLines with scroll region" { test "Terminal: insertLines more than remaining" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -3889,7 +3898,7 @@ test "Terminal: insertLines more than remaining" { test "Terminal: insertLines resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -3907,7 +3916,7 @@ test "Terminal: insertLines resets wrap" { test "Terminal: insertLines multi-codepoint graphemes" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Disable grapheme clustering @@ -3939,7 +3948,7 @@ test "Terminal: insertLines multi-codepoint graphemes" { test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -3963,7 +3972,7 @@ test "Terminal: insertLines left/right scroll region" { test "Terminal: scrollUp simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -3988,7 +3997,7 @@ test "Terminal: scrollUp simple" { test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4011,7 +4020,7 @@ test "Terminal: scrollUp top/bottom scroll region" { test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -4038,7 +4047,7 @@ test "Terminal: scrollUp left/right scroll region" { test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -4059,7 +4068,7 @@ test "Terminal: scrollUp preserves pending wrap" { test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("top"); @@ -4077,7 +4086,7 @@ test "Terminal: scrollUp full top/bottom region" { test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("top"); @@ -4097,7 +4106,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { test "Terminal: scrollDown simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4122,7 +4131,7 @@ test "Terminal: scrollDown simple" { test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4148,7 +4157,7 @@ test "Terminal: scrollDown outside of scroll region" { test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -4175,7 +4184,7 @@ test "Terminal: scrollDown left/right scroll region" { test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -4202,7 +4211,7 @@ test "Terminal: scrollDown outside of left/right scroll region" { test "Terminal: scrollDown preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -4223,7 +4232,7 @@ test "Terminal: scrollDown preserves pending wrap" { test "Terminal: eraseChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -4240,7 +4249,7 @@ test "Terminal: eraseChars simple operation" { test "Terminal: eraseChars minimum one" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -4257,7 +4266,7 @@ test "Terminal: eraseChars minimum one" { test "Terminal: eraseChars beyond screen edge" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for (" ABC") |c| try t.print(c); @@ -4273,7 +4282,7 @@ test "Terminal: eraseChars beyond screen edge" { test "Terminal: eraseChars wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('橋'); @@ -4291,7 +4300,7 @@ test "Terminal: eraseChars wide character" { test "Terminal: eraseChars resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -4309,7 +4318,7 @@ test "Terminal: eraseChars resets pending wrap" { test "Terminal: eraseChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE123") |c| try t.print(c); @@ -4339,7 +4348,7 @@ test "Terminal: eraseChars resets wrap" { test "Terminal: eraseChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -4378,7 +4387,7 @@ test "Terminal: eraseChars preserves background sgr" { test "Terminal: eraseChars handles refcounted styles" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -4400,7 +4409,7 @@ test "Terminal: eraseChars handles refcounted styles" { test "Terminal: eraseChars protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -4417,7 +4426,7 @@ test "Terminal: eraseChars protected attributes respected with iso" { test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -4436,7 +4445,7 @@ test "Terminal: eraseChars protected attributes ignored with dec most recent" { test "Terminal: eraseChars protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -4453,7 +4462,7 @@ test "Terminal: eraseChars protected attributes ignored with dec set" { test "Terminal: reverseIndex" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -4480,7 +4489,7 @@ test "Terminal: reverseIndex" { test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4513,7 +4522,7 @@ test "Terminal: reverseIndex from the top" { test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 10); + var t = try init(alloc, .{ .cols = 2, .rows = 10 }); defer t.deinit(alloc); // Initial value @@ -4546,7 +4555,7 @@ test "Terminal: reverseIndex top of scrolling region" { test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4567,7 +4576,7 @@ test "Terminal: reverseIndex top of screen" { test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4588,7 +4597,7 @@ test "Terminal: reverseIndex not top of screen" { test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4609,7 +4618,7 @@ test "Terminal: reverseIndex top/bottom margins" { test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4630,7 +4639,7 @@ test "Terminal: reverseIndex outside top/bottom margins" { test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4652,7 +4661,7 @@ test "Terminal: reverseIndex left/right margins" { test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -4674,7 +4683,7 @@ test "Terminal: reverseIndex outside left/right margins" { test "Terminal: index" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.index(); @@ -4689,7 +4698,7 @@ test "Terminal: index" { test "Terminal: index from the bottom" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -4708,7 +4717,7 @@ test "Terminal: index from the bottom" { test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); @@ -4719,7 +4728,7 @@ test "Terminal: index outside of scrolling region" { test "Terminal: index from the bottom outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 2); @@ -4737,7 +4746,7 @@ test "Terminal: index from the bottom outside of scroll region" { test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4753,7 +4762,7 @@ test "Terminal: index no scroll region, top of screen" { test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -4770,7 +4779,7 @@ test "Terminal: index bottom of primary screen" { test "Terminal: index bottom of primary screen background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -4800,7 +4809,7 @@ test "Terminal: index bottom of primary screen background sgr" { test "Terminal: index inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4817,7 +4826,7 @@ test "Terminal: index inside scroll region" { test "Terminal: index bottom of primary screen with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4838,7 +4847,7 @@ test "Terminal: index bottom of primary screen with scroll region" { test "Terminal: index outside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4859,7 +4868,7 @@ test "Terminal: index outside left/right margin" { test "Terminal: index inside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.printString("AAAAAA"); @@ -4887,7 +4896,7 @@ test "Terminal: index inside left/right margin" { test "Terminal: index bottom of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -4907,7 +4916,7 @@ test "Terminal: index bottom of scroll region" { test "Terminal: cursorUp basic" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(3, 1); @@ -4924,7 +4933,7 @@ test "Terminal: cursorUp basic" { test "Terminal: cursorUp below top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(2, 4); @@ -4942,7 +4951,7 @@ test "Terminal: cursorUp below top scroll margin" { test "Terminal: cursorUp above top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(3, 5); @@ -4961,7 +4970,7 @@ test "Terminal: cursorUp above top scroll margin" { test "Terminal: cursorUp resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -4979,7 +4988,7 @@ test "Terminal: cursorUp resets wrap" { test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -4997,7 +5006,7 @@ test "Terminal: cursorLeft no wrap" { test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -5015,7 +5024,7 @@ test "Terminal: cursorLeft unsets pending wrap state" { test "Terminal: cursorLeft unsets pending wrap state with longer jump" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -5033,7 +5042,7 @@ test "Terminal: cursorLeft unsets pending wrap state with longer jump" { test "Terminal: cursorLeft reverse wrap with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5054,7 +5063,7 @@ test "Terminal: cursorLeft reverse wrap with pending wrap state" { test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5075,7 +5084,7 @@ test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5095,7 +5104,7 @@ test "Terminal: cursorLeft reverse wrap" { test "Terminal: cursorLeft reverse wrap with no soft wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5117,7 +5126,7 @@ test "Terminal: cursorLeft reverse wrap with no soft wrap" { test "Terminal: cursorLeft reverse wrap before left margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5135,7 +5144,7 @@ test "Terminal: cursorLeft reverse wrap before left margin" { test "Terminal: cursorLeft extended reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5157,7 +5166,7 @@ test "Terminal: cursorLeft extended reverse wrap" { test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5179,7 +5188,7 @@ test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5202,7 +5211,7 @@ test "Terminal: cursorLeft extended reverse wrap is priority if both set" { test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5218,7 +5227,7 @@ test "Terminal: cursorLeft extended reverse wrap above top scroll region" { test "Terminal: cursorLeft reverse wrap on first row" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -5234,7 +5243,7 @@ test "Terminal: cursorLeft reverse wrap on first row" { test "Terminal: cursorDown basic" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -5250,7 +5259,7 @@ test "Terminal: cursorDown basic" { test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -5267,7 +5276,7 @@ test "Terminal: cursorDown above bottom scroll margin" { test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -5285,7 +5294,7 @@ test "Terminal: cursorDown below bottom scroll margin" { test "Terminal: cursorDown resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -5303,7 +5312,7 @@ test "Terminal: cursorDown resets wrap" { test "Terminal: cursorRight resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -5321,7 +5330,7 @@ test "Terminal: cursorRight resets wrap" { test "Terminal: cursorRight to the edge of screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.cursorRight(100); @@ -5336,7 +5345,7 @@ test "Terminal: cursorRight to the edge of screen" { test "Terminal: cursorRight left of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.right = 2; @@ -5352,7 +5361,7 @@ test "Terminal: cursorRight left of right margin" { test "Terminal: cursorRight right of right margin" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.right = 2; @@ -5369,7 +5378,7 @@ test "Terminal: cursorRight right of right margin" { test "Terminal: deleteLines simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -5391,7 +5400,7 @@ test "Terminal: deleteLines simple" { test "Terminal: deleteLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -5429,7 +5438,7 @@ test "Terminal: deleteLines colors with bg color" { test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -5464,7 +5473,7 @@ test "Terminal: deleteLines (legacy)" { test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -5501,7 +5510,7 @@ test "Terminal: deleteLines with scroll region" { // X test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -5538,7 +5547,7 @@ test "Terminal: deleteLines with scroll region, large count" { // X test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; - var t = try init(alloc, 80, 80); + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -5566,7 +5575,7 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { test "Terminal: deleteLines resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -5584,7 +5593,7 @@ test "Terminal: deleteLines resets wrap" { test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -5608,7 +5617,7 @@ test "Terminal: deleteLines left/right scroll region" { test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -5632,7 +5641,7 @@ test "Terminal: deleteLines left/right scroll region from top" { test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -5656,7 +5665,7 @@ test "Terminal: deleteLines left/right scroll region high count" { test "Terminal: default style is empty" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -5671,7 +5680,7 @@ test "Terminal: default style is empty" { test "Terminal: bold style" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -5688,7 +5697,7 @@ test "Terminal: bold style" { test "Terminal: garbage collect overwritten" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -5711,7 +5720,7 @@ test "Terminal: garbage collect overwritten" { test "Terminal: do not garbage collect old styles in use" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -5733,7 +5742,7 @@ test "Terminal: do not garbage collect old styles in use" { test "Terminal: print with style marks the row as styled" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -5749,7 +5758,7 @@ test "Terminal: print with style marks the row as styled" { test "Terminal: DECALN" { const alloc = testing.allocator; - var t = try init(alloc, 2, 2); + var t = try init(alloc, .{ .cols = 2, .rows = 2 }); defer t.deinit(alloc); // Initial value @@ -5771,7 +5780,7 @@ test "Terminal: DECALN" { test "Terminal: decaln reset margins" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -5789,7 +5798,7 @@ test "Terminal: decaln reset margins" { test "Terminal: decaln preserves color" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -5820,7 +5829,7 @@ test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -5840,7 +5849,7 @@ test "Terminal: insertBlanks pushes off end" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -5860,7 +5869,7 @@ test "Terminal: insertBlanks more than size" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -5878,7 +5887,7 @@ test "Terminal: insertBlanks more than size" { test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -5894,7 +5903,7 @@ test "Terminal: insertBlanks no scroll region, fits" { test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -5924,7 +5933,7 @@ test "Terminal: insertBlanks preserves background sgr" { test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); for (" ABC") |c| try t.print(c); @@ -5941,7 +5950,7 @@ test "Terminal: insertBlanks shift off screen" { test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 10); + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); for ("123") |c| try t.print(c); @@ -5958,7 +5967,7 @@ test "Terminal: insertBlanks split multi-cell character" { test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.scrolling_region.left = 2; @@ -5978,7 +5987,7 @@ test "Terminal: insertBlanks inside left/right scroll region" { test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); t.setCursorPos(1, 4); @@ -5999,7 +6008,7 @@ test "Terminal: insertBlanks outside left/right scroll region" { test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -6018,7 +6027,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); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Disable grapheme clustering @@ -6052,7 +6061,7 @@ test "Terminal: insertBlanks deleting graphemes" { test "Terminal: insertBlanks shift graphemes" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Disable grapheme clustering @@ -6086,7 +6095,7 @@ test "Terminal: insertBlanks shift graphemes" { test "Terminal: insert mode with space" { const alloc = testing.allocator; - var t = try init(alloc, 10, 2); + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -6103,7 +6112,7 @@ test "Terminal: insert mode with space" { test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -6120,7 +6129,7 @@ test "Terminal: insert mode doesn't wrap pushed characters" { test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -6136,7 +6145,7 @@ test "Terminal: insert mode does nothing at the end of the line" { test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -6153,7 +6162,7 @@ test "Terminal: insert mode with wide characters" { test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("well") |c| try t.print(c); @@ -6169,7 +6178,7 @@ test "Terminal: insert mode with wide characters at end" { test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 2); + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("123") |c| try t.print(c); @@ -6187,7 +6196,7 @@ test "Terminal: insert mode pushing off wide character" { test "Terminal: deleteChars" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6203,7 +6212,7 @@ test "Terminal: deleteChars" { test "Terminal: deleteChars zero count" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6219,7 +6228,7 @@ test "Terminal: deleteChars zero count" { test "Terminal: deleteChars more than half" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6235,7 +6244,7 @@ test "Terminal: deleteChars more than half" { test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6251,7 +6260,7 @@ test "Terminal: deleteChars more than line width" { test "Terminal: deleteChars should shift left" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6267,7 +6276,7 @@ test "Terminal: deleteChars should shift left" { test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6285,7 +6294,7 @@ test "Terminal: deleteChars resets wrap" { test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -6301,7 +6310,7 @@ test "Terminal: deleteChars simple operation" { test "Terminal: deleteChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 10, 10); + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC123") |c| try t.print(c); @@ -6331,7 +6340,7 @@ test "Terminal: deleteChars preserves background sgr" { test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -6350,7 +6359,7 @@ test "Terminal: deleteChars outside scroll region" { test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -6368,7 +6377,7 @@ test "Terminal: deleteChars inside scroll region" { test "Terminal: deleteChars split wide character" { const alloc = testing.allocator; - var t = try init(alloc, 6, 10); + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("A橋123"); @@ -6384,7 +6393,7 @@ test "Terminal: deleteChars split wide character" { test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, t.cols - 1); @@ -6402,7 +6411,7 @@ test "Terminal: deleteChars split wide character tail" { test "Terminal: saveCursor" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -6420,7 +6429,7 @@ test "Terminal: saveCursor" { test "Terminal: saveCursor with screen change" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -6451,7 +6460,7 @@ test "Terminal: saveCursor with screen change" { test "Terminal: saveCursor position" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -6471,7 +6480,7 @@ test "Terminal: saveCursor position" { test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -6491,7 +6500,7 @@ test "Terminal: saveCursor pending wrap state" { test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -6511,7 +6520,7 @@ test "Terminal: saveCursor origin mode" { test "Terminal: saveCursor resize" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 10); @@ -6529,7 +6538,7 @@ test "Terminal: saveCursor resize" { test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6544,7 +6553,7 @@ test "Terminal: saveCursor protected pen" { test "Terminal: setProtectedMode" { const alloc = testing.allocator; - var t = try init(alloc, 3, 3); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.screen.cursor.protected); @@ -6560,7 +6569,7 @@ test "Terminal: setProtectedMode" { test "Terminal: eraseLine simple erase right" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6576,7 +6585,7 @@ test "Terminal: eraseLine simple erase right" { test "Terminal: eraseLine resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6594,7 +6603,7 @@ test "Terminal: eraseLine resets pending wrap" { test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE123") |c| try t.print(c); @@ -6621,7 +6630,7 @@ test "Terminal: eraseLine resets wrap" { test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6651,7 +6660,7 @@ test "Terminal: eraseLine right preserves background sgr" { test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("AB") |c| try t.print(c); @@ -6669,7 +6678,7 @@ test "Terminal: eraseLine right wide character" { test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6686,7 +6695,7 @@ test "Terminal: eraseLine right protected attributes respected with iso" { test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6705,7 +6714,7 @@ test "Terminal: eraseLine right protected attributes ignored with dec most recen test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -6722,7 +6731,7 @@ test "Terminal: eraseLine right protected attributes ignored with dec set" { test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("12345678") |c| try t.print(c); @@ -6741,7 +6750,7 @@ test "Terminal: eraseLine right protected requested" { test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6757,7 +6766,7 @@ test "Terminal: eraseLine simple erase left" { test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6775,7 +6784,7 @@ test "Terminal: eraseLine left resets wrap" { test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6805,7 +6814,7 @@ test "Terminal: eraseLine left preserves background sgr" { test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("AB") |c| try t.print(c); @@ -6823,7 +6832,7 @@ test "Terminal: eraseLine left wide character" { test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6840,7 +6849,7 @@ test "Terminal: eraseLine left protected attributes respected with iso" { test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6859,7 +6868,7 @@ test "Terminal: eraseLine left protected attributes ignored with dec most recent test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -6876,7 +6885,7 @@ test "Terminal: eraseLine left protected attributes ignored with dec set" { test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("123456789") |c| try t.print(c); @@ -6895,7 +6904,7 @@ test "Terminal: eraseLine left protected requested" { test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6925,7 +6934,7 @@ test "Terminal: eraseLine complete preserves background sgr" { test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6942,7 +6951,7 @@ test "Terminal: eraseLine complete protected attributes respected with iso" { test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -6961,7 +6970,7 @@ test "Terminal: eraseLine complete protected attributes ignored with dec most re test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -6978,7 +6987,7 @@ test "Terminal: eraseLine complete protected attributes ignored with dec set" { test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("123456789") |c| try t.print(c); @@ -6997,7 +7006,7 @@ test "Terminal: eraseLine complete protected requested" { test "Terminal: tabClear single" { const alloc = testing.allocator; - var t = try init(alloc, 30, 5); + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); try t.horizontalTab(); @@ -7009,7 +7018,7 @@ test "Terminal: tabClear single" { test "Terminal: tabClear all" { const alloc = testing.allocator; - var t = try init(alloc, 30, 5); + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); t.tabClear(.all); @@ -7020,7 +7029,7 @@ test "Terminal: tabClear all" { test "Terminal: printRepeat simple" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("A"); @@ -7035,7 +7044,7 @@ test "Terminal: printRepeat simple" { test "Terminal: printRepeat wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString(" A"); @@ -7050,7 +7059,7 @@ test "Terminal: printRepeat wrap" { test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printRepeat(1); @@ -7064,7 +7073,7 @@ test "Terminal: printRepeat no previous character" { test "Terminal: printAttributes" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); var storage: [64]u8 = undefined; @@ -7115,7 +7124,7 @@ test "Terminal: printAttributes" { test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -7137,7 +7146,7 @@ test "Terminal: eraseDisplay simple erase below" { test "Terminal: eraseDisplay erase below preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -7174,7 +7183,7 @@ test "Terminal: eraseDisplay erase below preserves SGR bg" { test "Terminal: eraseDisplay below split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("AB橋C"); @@ -7196,7 +7205,7 @@ test "Terminal: eraseDisplay below split multi-cell" { test "Terminal: eraseDisplay below protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -7219,7 +7228,7 @@ test "Terminal: eraseDisplay below protected attributes respected with iso" { test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -7244,7 +7253,7 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec most re test "Terminal: eraseDisplay below protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -7267,7 +7276,7 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec set" { test "Terminal: eraseDisplay below protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -7290,7 +7299,7 @@ test "Terminal: eraseDisplay below protected attributes respected with force" { test "Terminal: eraseDisplay simple erase above" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -7312,7 +7321,7 @@ test "Terminal: eraseDisplay simple erase above" { test "Terminal: eraseDisplay erase above preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -7349,7 +7358,7 @@ test "Terminal: eraseDisplay erase above preserves SGR bg" { test "Terminal: eraseDisplay above split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("AB橋C"); @@ -7371,7 +7380,7 @@ test "Terminal: eraseDisplay above split multi-cell" { test "Terminal: eraseDisplay above protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -7394,7 +7403,7 @@ test "Terminal: eraseDisplay above protected attributes respected with iso" { test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -7419,7 +7428,7 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec most re test "Terminal: eraseDisplay above protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -7442,7 +7451,7 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec set" { test "Terminal: eraseDisplay above protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -7465,7 +7474,7 @@ test "Terminal: eraseDisplay above protected attributes respected with force" { test "Terminal: eraseDisplay protected complete" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -7487,7 +7496,7 @@ test "Terminal: eraseDisplay protected complete" { test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -7509,7 +7518,7 @@ test "Terminal: eraseDisplay protected below" { test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; - var t = try init(alloc, 10, 5); + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -7526,7 +7535,7 @@ test "Terminal: eraseDisplay scroll complete" { test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; - var t = try init(alloc, 10, 3); + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try t.print('A'); @@ -7548,7 +7557,7 @@ test "Terminal: eraseDisplay protected above" { test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); @@ -7578,7 +7587,7 @@ test "Terminal: cursorIsAtPrompt" { test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; - var t = try init(alloc, 3, 2); + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); @@ -7593,7 +7602,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { } test "Terminal: fullReset with a non-empty pen" { - var t = try init(testing.allocator, 80, 80); + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); @@ -7611,7 +7620,7 @@ test "Terminal: fullReset with a non-empty pen" { } test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, 10, 10); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.setCursorPos(3, 5); @@ -7625,7 +7634,7 @@ test "Terminal: fullReset origin mode" { } test "Terminal: fullReset status display" { - var t = try init(testing.allocator, 10, 10); + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.status_display = .status_line; @@ -7638,7 +7647,7 @@ test "Terminal: fullReset status display" { // 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); + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.print('x'); @@ -7654,7 +7663,7 @@ 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); + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.enable_left_and_right_margin, true); @@ -7672,7 +7681,7 @@ test "Terminal: resize with wraparound off" { const alloc = testing.allocator; const cols = 4; const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.wraparound, false); @@ -7692,7 +7701,7 @@ test "Terminal: resize with wraparound on" { const alloc = testing.allocator; const cols = 4; const rows = 2; - var t = try init(alloc, cols, rows); + var t = try init(alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -7710,7 +7719,7 @@ test "Terminal: resize with wraparound on" { test "Terminal: resize with high unique style per cell" { const alloc = testing.allocator; - var t = try init(alloc, 30, 30); + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); for (0..t.rows) |y| { @@ -7730,7 +7739,7 @@ test "Terminal: resize with high unique style per cell" { test "Terminal: resize with high unique style per cell with wrapping" { const alloc = testing.allocator; - var t = try init(alloc, 30, 30); + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); const cell_count: u16 = @intCast(t.rows * t.cols); @@ -7751,7 +7760,7 @@ test "Terminal: resize with high unique style per cell with wrapping" { test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.@"132_column", true); @@ -7763,7 +7772,7 @@ test "Terminal: DECCOLM without DEC mode 40" { test "Terminal: DECCOLM unset" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_mode_3, true); @@ -7774,7 +7783,7 @@ test "Terminal: DECCOLM unset" { test "Terminal: DECCOLM resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -7789,7 +7798,7 @@ test "Terminal: DECCOLM resets pending wrap" { test "Terminal: DECCOLM preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .direct_color_bg = .{ @@ -7813,7 +7822,7 @@ test "Terminal: DECCOLM preserves SGR bg" { test "Terminal: DECCOLM resets scroll region" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_left_and_right_margin, true); diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 534d1b33f0..231c8fddd2 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -645,7 +645,7 @@ fn trackPin( test "storage: add placement with zero placement id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; @@ -674,7 +674,7 @@ test "storage: add placement with zero placement id" { test "storage: delete all placements and images" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -697,7 +697,7 @@ test "storage: delete all placements and images" { test "storage: delete all placements and images preserves limit" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -722,7 +722,7 @@ test "storage: delete all placements and images preserves limit" { test "storage: delete all placements" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -745,7 +745,7 @@ test "storage: delete all placements" { test "storage: delete all placements by image id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -768,7 +768,7 @@ test "storage: delete all placements by image id" { test "storage: delete all placements by image id and unused images" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -791,7 +791,7 @@ test "storage: delete all placements by image id and unused images" { test "storage: delete placement by specific id" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); const tracked = t.screen.pages.countTrackedPins(); @@ -819,7 +819,7 @@ test "storage: delete placement by specific id" { test "storage: delete intersecting cursor" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; @@ -851,7 +851,7 @@ test "storage: delete intersecting cursor" { test "storage: delete intersecting cursor plus unused" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; @@ -883,7 +883,7 @@ test "storage: delete intersecting cursor plus unused" { test "storage: delete intersecting cursor hits multiple" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; @@ -909,7 +909,7 @@ test "storage: delete intersecting cursor hits multiple" { test "storage: delete by column" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; @@ -942,7 +942,7 @@ test "storage: delete by column" { test "storage: delete by row" { const testing = std.testing; const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); + var t = try terminal.Terminal.init(alloc, .{ .rows = 100, .cols = 100 }); defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 3e0ff071b9..0acfe59a00 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -128,11 +128,11 @@ pub const DerivedConfig = struct { /// process. pub fn init(alloc: Allocator, opts: termio.Options) !Exec { // Create our terminal - var term = try terminal.Terminal.init( - alloc, - opts.grid_size.columns, - opts.grid_size.rows, - ); + var term = try terminal.Terminal.init(alloc, .{ + .cols = opts.grid_size.columns, + .rows = opts.grid_size.rows, + .max_scrollback = opts.full_config.@"scrollback-limit", + }); errdefer term.deinit(alloc); term.default_palette = opts.config.palette; term.color_palette.colors = opts.config.palette; From 3191081ea6a71d25a9d73151248b819791740e75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 21:10:28 -0700 Subject: [PATCH 281/428] terminal: page.cloneFrom graphemes --- src/terminal/page.zig | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 0e53696a7c..900ee26bec 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -259,6 +259,7 @@ pub const Page = struct { for (cells, other_cells) |*dst_cell, *src_cell| { dst_cell.* = src_cell.*; if (src_cell.hasGrapheme()) { + dst_cell.content_tag = .codepoint; // required for appendGrapheme const cps = other.lookupGrapheme(src_cell).?; for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); } @@ -1161,3 +1162,65 @@ test "Page cloneFrom partial" { try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); } } + +test "Page cloneFrom graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + page.clearGrapheme(rac.row, rac.cell); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} From 65909df9f9cd8378478ab44ba2a5c653e6d5b81e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 21:13:55 -0700 Subject: [PATCH 282/428] terminal: commented log line to see active style count --- src/terminal/Screen.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index de75173079..3883a4270f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -949,6 +949,8 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { pub fn manualStyleUpdate(self: *Screen) !void { var page = &self.cursor.page_pin.page.data; + // std.log.warn("active styles={}", .{page.styles.count(page.memory)}); + // Remove our previous style if is unused. if (self.cursor.style_ref) |ref| { if (ref.* == 0) { From 37251dca95f74f2e44d9cad31efeeb4de935bea6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 21:19:25 -0700 Subject: [PATCH 283/428] fix bench compilation --- src/bench/page-init.zig | 11 ++++++----- src/bench/resize.zig | 14 +++++++------- src/bench/screen-copy.zig | 27 +++++++++++++-------------- src/bench/stream.zig | 18 ++++++++---------- src/bench/vt-insert-lines.zig | 14 +++++++------- src/terminal-old/main.zig | 3 --- 6 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/bench/page-init.zig b/src/bench/page-init.zig index 3f5e006d1b..c3057cd9f1 100644 --- a/src/bench/page-init.zig +++ b/src/bench/page-init.zig @@ -8,7 +8,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal = @import("../terminal-old/main.zig"); +const terminal_new = @import("../terminal/main.zig"); const Args = struct { mode: Mode = .alloc, @@ -59,15 +60,15 @@ pub fn main() !void { noinline fn benchAlloc(count: usize) !void { for (0..count) |_| { - _ = try terminal.new.Page.init(terminal.new.page.std_capacity); + _ = try terminal_new.Page.init(terminal_new.page.std_capacity); } } noinline fn benchPool(alloc: Allocator, count: usize) !void { - var list = try terminal.new.PageList.init( + var list = try terminal_new.PageList.init( alloc, - terminal.new.page.std_capacity.cols, - terminal.new.page.std_capacity.rows, + terminal_new.page.std_capacity.cols, + terminal_new.page.std_capacity.rows, 0, ); defer list.deinit(); diff --git a/src/bench/resize.zig b/src/bench/resize.zig index 53261486e9..d88803fe78 100644 --- a/src/bench/resize.zig +++ b/src/bench/resize.zig @@ -5,7 +5,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal = @import("../terminal-old/main.zig"); +const terminal_new = @import("../terminal/main.zig"); const Args = struct { mode: Mode = .old, @@ -60,11 +61,10 @@ pub fn main() !void { }, .new => { - var t = try terminal.new.Terminal.init( - alloc, - @intCast(args.cols), - @intCast(args.rows), - ); + var t = try terminal_new.Terminal.init(alloc, .{ + .cols = @intCast(args.cols), + .rows = @intCast(args.rows), + }); defer t.deinit(alloc); try benchNew(&t, args); }, @@ -90,7 +90,7 @@ noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { } } -noinline fn benchNew(t: *terminal.new.Terminal, args: Args) !void { +noinline fn benchNew(t: *terminal_new.Terminal, args: Args) !void { // We fill the terminal with letters. for (0..args.rows) |row| { for (0..args.cols) |col| { diff --git a/src/bench/screen-copy.zig b/src/bench/screen-copy.zig index 1c2b05153f..15cc76658e 100644 --- a/src/bench/screen-copy.zig +++ b/src/bench/screen-copy.zig @@ -5,7 +5,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal = @import("../terminal-old/main.zig"); +const terminal_new = @import("../terminal/main.zig"); const Args = struct { mode: Mode = .old, @@ -61,21 +62,19 @@ pub fn main() !void { }, .new => { - var t = try terminal.new.Terminal.init( - alloc, - @intCast(args.cols), - @intCast(args.rows), - ); + var t = try terminal_new.Terminal.init(alloc, .{ + .cols = @intCast(args.cols), + .rows = @intCast(args.rows), + }); defer t.deinit(alloc); try benchNew(alloc, &t, args); }, .@"new-pooled" => { - var t = try terminal.new.Terminal.init( - alloc, - @intCast(args.cols), - @intCast(args.rows), - ); + var t = try terminal_new.Terminal.init(alloc, .{ + .cols = @intCast(args.cols), + .rows = @intCast(args.rows), + }); defer t.deinit(alloc); try benchNewPooled(alloc, &t, args); }, @@ -101,7 +100,7 @@ noinline fn benchOld(alloc: Allocator, t: *terminal.Terminal, args: Args) !void } } -noinline fn benchNew(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !void { +noinline fn benchNew(alloc: Allocator, t: *terminal_new.Terminal, args: Args) !void { // We fill the terminal with letters. for (0..args.rows) |row| { for (0..args.cols) |col| { @@ -116,7 +115,7 @@ noinline fn benchNew(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !v } } -noinline fn benchNewPooled(alloc: Allocator, t: *terminal.new.Terminal, args: Args) !void { +noinline fn benchNewPooled(alloc: Allocator, t: *terminal_new.Terminal, args: Args) !void { // We fill the terminal with letters. for (0..args.rows) |row| { for (0..args.cols) |col| { @@ -125,7 +124,7 @@ noinline fn benchNewPooled(alloc: Allocator, t: *terminal.new.Terminal, args: Ar } } - var pool = try terminal.new.PageList.MemoryPool.init(alloc, std.heap.page_allocator, 4); + var pool = try terminal_new.PageList.MemoryPool.init(alloc, std.heap.page_allocator, 4); defer pool.deinit(); for (0..args.count) |_| { diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 4d7586be4f..631c35ea1b 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -99,11 +99,10 @@ pub fn main() !void { defer f.close(); const r = f.reader(); const TerminalStream = terminal.Stream(*NewTerminalHandler); - var t = try terminalnew.Terminal.init( - alloc, - @intCast(args.@"terminal-cols"), - @intCast(args.@"terminal-rows"), - ); + var t = try terminalnew.Terminal.init(alloc, .{ + .cols = @intCast(args.@"terminal-cols"), + .rows = @intCast(args.@"terminal-rows"), + }); var handler: NewTerminalHandler = .{ .t = &t }; var stream: TerminalStream = .{ .handler = &handler }; try benchSimd(r, &stream, buf); @@ -141,11 +140,10 @@ pub fn main() !void { .new => { const TerminalStream = terminal.Stream(*NewTerminalHandler); - var t = try terminalnew.Terminal.init( - alloc, - @intCast(args.@"terminal-cols"), - @intCast(args.@"terminal-rows"), - ); + var t = try terminalnew.Terminal.init(alloc, .{ + .cols = @intCast(args.@"terminal-cols"), + .rows = @intCast(args.@"terminal-rows"), + }); var handler: NewTerminalHandler = .{ .t = &t }; var stream: TerminalStream = .{ .handler = &handler }; switch (tag) { diff --git a/src/bench/vt-insert-lines.zig b/src/bench/vt-insert-lines.zig index 415e648956..d61d5354d5 100644 --- a/src/bench/vt-insert-lines.zig +++ b/src/bench/vt-insert-lines.zig @@ -5,7 +5,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); +const terminal = @import("../terminal-old/main.zig"); +const terminal_new = @import("../terminal/main.zig"); const Args = struct { mode: Mode = .old, @@ -60,11 +61,10 @@ pub fn main() !void { }, .new => { - var t = try terminal.new.Terminal.init( - alloc, - @intCast(args.cols), - @intCast(args.rows), - ); + var t = try terminal_new.Terminal.init(alloc, .{ + .cols = @intCast(args.cols), + .rows = @intCast(args.rows), + }); defer t.deinit(alloc); try benchNew(&t, args); }, @@ -87,7 +87,7 @@ noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { } } -noinline fn benchNew(t: *terminal.new.Terminal, args: Args) !void { +noinline fn benchNew(t: *terminal_new.Terminal, args: Args) !void { // We fill the terminal with letters. for (0..args.rows) |row| { for (0..args.cols) |col| { diff --git a/src/terminal-old/main.zig b/src/terminal-old/main.zig index e886d97c1f..5f29ce70c7 100644 --- a/src/terminal-old/main.zig +++ b/src/terminal-old/main.zig @@ -50,9 +50,6 @@ 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()); } From e018059a5dab2b2002685a49e2516c9b912873c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Mar 2024 21:22:47 -0700 Subject: [PATCH 284/428] core: re-enable click to move cursor --- src/Surface.zig | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index aaae855c19..aed6ea84fc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2247,14 +2247,13 @@ pub fn mouseButtonCallback( } // For left button click release we check if we are moving our cursor. - if (button == .left and action == .release and mods.alt) { + if (button == .left and action == .release and mods.alt) click_move: { // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; - - // TODO(paged-terminal) - // self.renderer_state.mutex.lock(); - // defer self.renderer_state.mutex.unlock(); - // try self.clickMoveCursor(self.mouse.left_click_point); + const pin = self.mouse.left_click_pin orelse break :click_move; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + try self.clickMoveCursor(pin.*); return; } @@ -2388,7 +2387,7 @@ pub fn mouseButtonCallback( /// Performs the "click-to-move" logic to move the cursor to the given /// screen point if possible. This works by converting the path to the /// given point into a series of arrow key inputs. -fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { +fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // If click-to-move is disabled then we're done. if (!self.config.cursor_click_to_move) return; @@ -2405,10 +2404,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.point.ScreenPoint) !void { if (!t.flags.shell_redraws_prompt) return; // Get our path - const from = (terminal.point.Viewport{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - }).toScreen(&t.screen); + const from = t.screen.cursor.page_pin.*; const path = t.screen.promptPath(from, to); log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path }); From 1c4fb96e495aa789a2e374265de032bbcc99d0a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Mar 2024 09:27:08 -0700 Subject: [PATCH 285/428] terminal: fix page size calculations on Linux --- src/terminal/page.zig | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 900ee26bec..591a903df0 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const testing = std.testing; @@ -605,10 +606,22 @@ pub const Capacity = struct { adjusted.rows = @intCast(new_rows); } - if (comptime std.debug.runtime_safety) { - const old_size = Page.layout(self).total_size; - const new_size = Page.layout(adjusted).total_size; - assert(new_size == old_size); + // Adjust our rows so that we have an exact total size count. + // I think we could do this with basic math but my grade school + // algebra skills are failing me and I'm embarassed so please someone + // fix this. This is tested so you can fiddle around. + const old_size = Page.layout(self).total_size; + var new_size = Page.layout(adjusted).total_size; + while (old_size != new_size) { + // Our math above is usually PRETTY CLOSE (like within 1 row) + // so we can just adjust by 1 row at a time. + if (new_size > old_size) { + adjusted.rows -= 1; + } else { + adjusted.rows += 1; + } + + new_size = Page.layout(adjusted).total_size; } return adjusted; @@ -839,15 +852,15 @@ pub const Cell = packed struct(u64) { // total_size / std.mem.page_size, // }); // } - -test "Page std size" { - // We want to ensure that the standard capacity is what we - // expect it to be. Changing this is fine but should be done with care - // so we fail a test if it changes. - const total_size = Page.layout(std_capacity).total_size; - try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB - //const pages = total_size / std.mem.page_size; -} +// +// test "Page std size" { +// // We want to ensure that the standard capacity is what we +// // expect it to be. Changing this is fine but should be done with care +// // so we fail a test if it changes. +// const total_size = Page.layout(std_capacity).total_size; +// try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB +// //const pages = total_size / std.mem.page_size; +// } test "Page capacity adjust cols down" { const original = std_capacity; From 0a6735d05dc8279f8ed399753c401487aec490ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Mar 2024 11:11:57 -0700 Subject: [PATCH 286/428] terminal: jump to prompt --- src/terminal/PageList.zig | 114 ++++++++++++++++++++++++++++++++++++++ src/terminal/Screen.zig | 2 + src/termio/Exec.zig | 21 +++---- 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f1ffd521ed..fbf4b11c14 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1408,6 +1408,11 @@ pub const Scroll = union(enum) { /// Scroll up (negative) or down (positive) by the given number of /// rows. This is clamped to the "top" and "active" top left. delta_row: isize, + + /// 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 in that direction. + delta_prompt: isize, }; /// Scroll the viewport. This will never create new scrollback, allocate @@ -1417,6 +1422,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { .active => self.viewport = .{ .active = {} }, .top => self.viewport = .{ .top = {} }, + .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| { if (n == 0) return; @@ -1448,6 +1454,45 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } } +/// Jump the viewport forwards (positive) or backwards (negative) a set number of +/// prompts (delta). +fn scrollPrompt(self: *PageList, delta: isize) void { + // If we aren't jumping any prompts then we don't need to do anything. + if (delta == 0) return; + const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + var delta_rem: usize = delta_start; + + // Iterate and count the number of prompts we see. + const viewport_pin = self.getTopLeft(.viewport); + var it = viewport_pin.rowIterator(if (delta > 0) .right_down else .left_up, null); + _ = it.next(); // skip our own row + var prompt_pin: ?Pin = null; + while (it.next()) |next| { + const row = next.rowAndCell().row; + switch (row.semantic_prompt) { + .command, .unknown => {}, + .prompt, .prompt_continuation, .input => { + delta_rem -= 1; + prompt_pin = next; + }, + } + + if (delta_rem == 0) break; + } + + // If we found a prompt, we move to it. If the prompt is in the active + // area we keep our viewport as active because we can't scroll DOWN + // into the active area. Otherwise, we scroll up to the pin. + if (prompt_pin) |p| { + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + } else { + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; + } + } +} + /// Clear the screen by scrolling written contents up into the scrollback. /// This will not update the viewport. pub fn scrollClear(self: *PageList) !void { @@ -3062,6 +3107,75 @@ test "PageList scroll clear" { } } +test "PageList: jump zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(3); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + s.scroll(.{ .delta_prompt = 0 }); + try testing.expect(s.viewport == .active); +} + +test "Screen: jump to prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(3); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + // Jump back + { + s.scroll(.{ .delta_prompt = -1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + } + { + s.scroll(.{ .delta_prompt = -1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + } + + // Jump forward + { + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .active); + } + { + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .active); + } +} + test "PageList grow fit in capacity" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3883a4270f..414051fcfe 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -532,6 +532,7 @@ pub const Scroll = union(enum) { active, top, delta_row: isize, + delta_prompt: isize, }; /// Scroll the viewport of the terminal grid. @@ -545,6 +546,7 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), + .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 0acfe59a00..1dab117415 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -508,20 +508,13 @@ pub fn scrollViewport(self: *Exec, scroll: terminal.Terminal.ScrollViewport) !vo /// Jump the viewport to the prompt. pub fn jumpToPrompt(self: *Exec, delta: isize) !void { - _ = self; - _ = delta; - // TODO(paged-terminal) - // const wakeup: bool = wakeup: { - // self.renderer_state.mutex.lock(); - // defer self.renderer_state.mutex.unlock(); - // break :wakeup self.terminal.screen.jump(.{ - // .prompt_delta = delta, - // }); - // }; - // - // if (wakeup) { - // try self.renderer_wakeup.notify(); - // } + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.screen.scroll(.{ .delta_prompt = delta }); + } + + try self.renderer_wakeup.notify(); } /// Called when the child process exited abnormally but before From 1cdeacea343290c7e51f286fcd085910b12b625b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Mar 2024 11:19:25 -0700 Subject: [PATCH 287/428] core: remove incorrect std.meta.eql on selection --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index aed6ea84fc..f648d47d52 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1065,7 +1065,7 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { // again if it changed, since setting the clipboard can be an expensive // operation. const sel = sel_ orelse return; - if (prev_) |prev| if (std.meta.eql(sel, prev)) return; + if (prev_) |prev| if (sel.eql(prev)) return; // Check if our runtime supports the selection clipboard at all. // We can save a lot of work if it doesn't. From 58133043588bd15b832571fdf4b5bc05c7c5df75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 09:26:24 -0700 Subject: [PATCH 288/428] terminal: dumpString options --- src/terminal/PageList.zig | 4 ++-- src/terminal/Screen.zig | 25 ++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index fbf4b11c14..96a12513cc 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2309,7 +2309,7 @@ pub fn pageIterator( } /// Get the top-left of the screen for the given tag. -fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { +pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { return switch (tag) { // The full screen or history is always just the first page. .screen, .history => .{ .page = self.pages.first.? }, @@ -2343,7 +2343,7 @@ fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { /// Returns the bottom right of the screen for the given tag. This can /// return null because it is possible that a tag is not in the screen /// (e.g. history does not yet exist). -fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { +pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { return switch (tag) { .screen, .active => last: { const page = self.pages.last.?; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 414051fcfe..846d312da5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1667,17 +1667,28 @@ pub fn promptPath( return .{ .x = to_x - from_x, .y = to_y - from_y }; } +pub const DumpString = struct { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + tl: Pin, + br: ?Pin = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + 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. pub fn dumpString( self: *const Screen, writer: anytype, - tl: point.Point, + opts: DumpString, ) !void { var blank_rows: usize = 0; - var iter = self.pages.rowIterator(.right_down, tl, null); + var iter = opts.tl.rowIterator(.right_down, opts.br); while (iter.next()) |row_offset| { const rac = row_offset.rowAndCell(); const cells = cells: { @@ -1736,6 +1747,8 @@ pub fn dumpString( } } +/// You should use dumpString, this is a restricted version mostly for +/// legacy and convenience reasons for unit tests. pub fn dumpStringAlloc( self: *const Screen, alloc: Allocator, @@ -1743,7 +1756,13 @@ pub fn dumpStringAlloc( ) ![]const u8 { var builder = std.ArrayList(u8).init(alloc); defer builder.deinit(); - try self.dumpString(builder.writer(), tl); + + try self.dumpString(builder.writer(), .{ + .tl = self.pages.getTopLeft(tl), + .br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint, + .unwrap = false, + }); + return try builder.toOwnedSlice(); } From 992c7369862e8fa63924ce73a3c4b2cdf66db057 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 09:28:17 -0700 Subject: [PATCH 289/428] terminal: dumpScreen handles wrap --- src/terminal/Screen.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 846d312da5..50ff93fe27 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1691,6 +1691,7 @@ pub fn dumpString( var iter = opts.tl.rowIterator(.right_down, opts.br); while (iter.next()) |row_offset| { const rac = row_offset.rowAndCell(); + const row = rac.row; const cells = cells: { const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); break :cells cells[0..self.pages.cols]; @@ -1705,8 +1706,12 @@ pub fn dumpString( blank_rows = 0; } - // TODO: handle wrap - blank_rows += 1; + if (!row.wrap or !opts.unwrap) { + // If we're not wrapped, we always add a newline. + // If we are wrapped, we only add a new line if we're unwrapping + // soft-wrapped lines. + blank_rows += 1; + } var blank_cells: usize = 0; for (cells) |*cell| { From 3e247baef779d96bf50a1ca8250ffc9a5d78a923 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 09:29:56 -0700 Subject: [PATCH 290/428] core: write scrollback file works again --- src/Surface.zig | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f648d47d52..1fe23ae327 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3230,11 +3230,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this // command always works on the primary screen. - // TODO(paged-terminal): unwrap - try self.io.terminal.screen.dumpString( - file.writer(), - .{ .history = .{} }, - ); + const pages = &self.io.terminal.screen.pages; + if (pages.getBottomRight(.history)) |br| { + const tl = pages.getTopLeft(.history); + try self.io.terminal.screen.dumpString( + file.writer(), + .{ .tl = tl, .br = br, .unwrap = true }, + ); + } } // Get the final path From d805fdd672f37aa7812c3bd3361f04f607f0d71c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 09:32:46 -0700 Subject: [PATCH 291/428] core: mouse untracks pin in right screen --- src/Surface.zig | 5 +++-- src/terminal/Terminal.zig | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1fe23ae327..857055c776 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2298,9 +2298,10 @@ pub fn mouseButtonCallback( if (distance > max_distance) self.mouse.left_click_count = 0; } - // TODO(paged-terminal): untrack previous pin across screens if (self.mouse.left_click_pin) |prev| { - screen.pages.untrackPin(prev); + const pin_screen = t.getScreen(self.mouse.left_click_screen); + pin_screen.pages.untrackPin(prev); + self.mouse.left_click_pin = null; } // Store it diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f78d5ca97c..f7e2b333f0 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2105,6 +2105,14 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 { return self.pwd.items; } +/// Get the screen pointer for the given type. +pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { + return if (self.active_screen == t) + &self.screen + else + &self.secondary_screen; +} + /// Options for switching to the alternate screen. pub const AlternateScreenOptions = struct { cursor_save: bool = false, From 347c57f06168db36f8f888bd398d73a3f93c07f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 10:00:38 -0700 Subject: [PATCH 292/428] terminal: Selection.containedRow --- src/terminal/Selection.zig | 176 +++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 6ecc1f4a24..7343ddd767 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -278,6 +278,62 @@ pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { 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, s: *const Screen, pin: Pin) ?Selection { + 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 (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 init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?, + true, + ); + + if (p.y == tl.y) { + // If the selection is JUST this line, return it as-is. + if (p.y == br.y) { + return init(tl_pin, br_pin, false); + } + + // Selection top-left line matches only. + return init( + tl_pin, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + false, + ); + } + + // 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 init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + br_pin, + false, + ); + } + + // Row is somewhere between our selection lines so we return the full line. + return init( + s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, + s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, + false, + ); +} + /// Possible adjustments to the selection. pub const Adjustment = enum { left, @@ -1231,3 +1287,123 @@ test "Selection: contains, rectangle" { try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); } } + +test "Selection: containedRow" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, 10, 5, 0); + defer s.deinit(); + + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 4 } }).?, + ) == null); + + // Start line + try testing.expectEqual(Selection.init( + sel.start(), + s.pages.pin(.{ .screen = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); + + // End line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + sel.end(), + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + ).?); + + // Middle line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = s.pages.cols - 1, .y = 2 } }).?, + false, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + ).?); + } + + // Rectangle + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 4 } }).?, + ) == null); + + // Start line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); + + // End line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + ).?); + + // Middle line + try testing.expectEqual(Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 2 } }).?, + true, + ), sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + ).?); + } + + // Single-line selection + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?, + false, + ); + + // Not contained + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + ) == null); + try testing.expect(sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, + ) == null); + + // Contained + try testing.expectEqual(sel, sel.containedRow( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + ).?); + } +} From 9eeaa0d0a9daa8b51ebf4fa29b04cf469d6e42c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 10:03:10 -0700 Subject: [PATCH 293/428] renderer/metal: re-enable selection awareness for shaping --- src/renderer/Metal.zig | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 1b801403b0..f6f81de4d1 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1639,18 +1639,10 @@ fn rebuildCells( // We need to get this row's selection if there is one for proper // run splitting. const row_selection = sel: { - // TODO(paged-terminal) - // if (screen.selection) |sel| { - // const screen_point = (terminal.point.Viewport{ - // .x = 0, - // .y = y, - // }).toScreen(screen); - // if (sel.containedRow(screen, screen_point)) |row_sel| { - // break :sel row_sel; - // } - // } - - break :sel null; + const sel = screen.selection orelse break :sel null; + const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse + break :sel null; + break :sel sel.containedRow(screen, pin) orelse null; }; // Split our row into runs and shape each one. From 0a3f431d1bc8997daab83cce0ac56a0501487539 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 10:16:00 -0700 Subject: [PATCH 294/428] renderer/metal: almost bring back kitty images, some bugs --- src/renderer/Metal.zig | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f6f81de4d1..81ee1f2a42 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -681,11 +681,8 @@ pub fn updateFrame( // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if // it changes. - // TODO(paged-terminal) - if (false) { - if (state.terminal.screen.kitty_images.dirty) { - try self.prepKittyGraphics(state.terminal); - } + if (state.terminal.screen.kitty_images.dirty) { + try self.prepKittyGraphics(state.terminal); } break :critical .{ @@ -1231,11 +1228,8 @@ fn prepKittyGraphics( // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = (terminal.point.Viewport{}).toScreen(&t.screen); - const bot = (terminal.point.Viewport{ - .x = t.screen.cols - 1, - .y = t.screen.rows - 1, - }).toScreen(&t.screen); + const top = t.screen.pages.getTopLeft(.viewport); + const bot = t.screen.pages.getBottomRight(.viewport).?; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -1252,15 +1246,20 @@ fn prepKittyGraphics( // If the selection isn't within our viewport then skip it. const rect = p.rect(image, t); - if (rect.top_left.y > bot.y) continue; - if (rect.bottom_right.y < top.y) continue; + if (bot.before(rect.top_left)) continue; + if (rect.bottom_right.before(top)) continue; // If the top left is outside the viewport we need to calc an offset // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.y < t.screen.viewport) offset_y: { - const offset_cells = t.screen.viewport - rect.top_left.y; - const offset_pixels = offset_cells * self.grid_metrics.cell_height; - break :offset_y @intCast(offset_pixels); + const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { + break :offset_y 0; + // TODO(paged-terminal) + // const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + // const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + // std.log.warn("vp_y={} img_y={}", .{ vp_y, img_y }); + // const offset_cells = vp_y - img_y; + // const offset_pixels = offset_cells * self.grid_metrics.cell_height; + // break :offset_y @intCast(offset_pixels); } else 0; // We need to prep this image for upload if it isn't in the cache OR @@ -1304,7 +1303,13 @@ fn prepKittyGraphics( } // Convert our screen point to a viewport point - const viewport = p.point.toViewport(&t.screen); + const viewport = t.screen.pages.pointFromPin(.viewport, p.pin.*) orelse { + log.warn( + "failed to convert image point to viewport point image_id={} placement_id={}", + .{ kv.key_ptr.image_id, kv.key_ptr.placement_id.id }, + ); + continue; + }; // Calculate the source rectangle const source_x = @min(image.width, p.source_x); @@ -1326,8 +1331,8 @@ fn prepKittyGraphics( if (image.width > 0 and image.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = kv.key_ptr.image_id, - .x = @intCast(p.point.x), - .y = @intCast(viewport.y), + .x = @intCast(p.pin.x), + .y = @intCast(viewport.viewport.y), .z = p.z, .width = dest_width, .height = dest_height, From d7ee705a7a6c3e9a39a70fbc9d4500d0dcd1eb18 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 10:27:19 -0700 Subject: [PATCH 295/428] terminal/kitty: calculate cell height more efficiently --- src/terminal/kitty/graphics_exec.zig | 11 +++---- src/terminal/kitty/graphics_storage.zig | 44 +++++++++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 65c7f5bf78..0ea084795a 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -222,19 +222,16 @@ fn display( switch (d.cursor_movement) { .none => {}, .after => { - const rect = p.rect(img, terminal); - - // We can do better by doing this with pure internal screen state - // but this handles scroll regions. - const height = rect.bottom_right.y - rect.top_left.y; - for (0..height) |_| terminal.index() catch |err| { + // We use terminal.index to properly handle scroll regions. + const size = p.gridSize(img, terminal); + for (0..size.rows) |_| terminal.index() catch |err| { log.warn("failed to move cursor: {}", .{err}); break; }; terminal.setCursorPos( terminal.screen.cursor.y, - rect.bottom_right.x + 1, + p.pin.x + size.cols + 1, ); }, } diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 231c8fddd2..1071f065a3 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -581,23 +581,19 @@ pub const ImageStorage = struct { s.pages.untrackPin(self.pin); } - /// Returns a selection of the entire rectangle this placement - /// occupies within the screen. - pub fn rect( + /// Returns the size in grid cells that this placement takes up. + pub fn gridSize( self: Placement, image: Image, t: *const terminal.Terminal, - ) 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, - }; - br.x = @min(self.pin.x + self.columns, t.cols - 1); - - return .{ .top_left = self.pin.*, .bottom_right = br }; - } + ) struct { + cols: u32, + rows: u32, + } { + if (self.columns > 0 and self.rows > 0) return .{ + .cols = self.columns, + .rows = self.rows, + }; // Calculate our cell size. const terminal_width_f64: f64 = @floatFromInt(t.width_px); @@ -617,12 +613,26 @@ 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)) { + return .{ + .cols = width_cells, + .rows = height_cells, + }; + } + + /// Returns a selection of the entire rectangle this placement + /// occupies within the screen. + pub fn rect( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) Rect { + const grid_size = self.gridSize(image, t); + + var br = switch (self.pin.downOverflow(grid_size.rows)) { .offset => |v| v, .overflow => |v| v.end, }; - br.x = @min(self.pin.x + width_cells, t.cols - 1); + br.x = @min(self.pin.x + grid_size.cols, t.cols - 1); return .{ .top_left = self.pin.*, From a697e97e08603030d50bfcfc3a5f10b0725dbbd8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 11:05:03 -0700 Subject: [PATCH 296/428] renderer/metal: fix kitty image offset on screen --- src/renderer/Metal.zig | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 81ee1f2a42..801c8bd96e 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1252,14 +1252,12 @@ fn prepKittyGraphics( // If the top left is outside the viewport we need to calc an offset // so that we render (0, 0) with some offset for the texture. const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { - break :offset_y 0; - // TODO(paged-terminal) - // const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; - // const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - // std.log.warn("vp_y={} img_y={}", .{ vp_y, img_y }); - // const offset_cells = vp_y - img_y; - // const offset_pixels = offset_cells * self.grid_metrics.cell_height; - // break :offset_y @intCast(offset_pixels); + const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + std.log.warn("vp_y={} img_y={}", .{ vp_y, img_y }); + const offset_cells = vp_y - img_y; + const offset_pixels = offset_cells * self.grid_metrics.cell_height; + break :offset_y @intCast(offset_pixels); } else 0; // We need to prep this image for upload if it isn't in the cache OR @@ -1303,13 +1301,10 @@ fn prepKittyGraphics( } // Convert our screen point to a viewport point - const viewport = t.screen.pages.pointFromPin(.viewport, p.pin.*) orelse { - log.warn( - "failed to convert image point to viewport point image_id={} placement_id={}", - .{ kv.key_ptr.image_id, kv.key_ptr.placement_id.id }, - ); - continue; - }; + const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + .viewport, + p.pin.*, + ) orelse .{ .viewport = .{} }; // Calculate the source rectangle const source_x = @min(image.width, p.source_x); From 1527936f901c10fa2e6db356dba641494aa0291d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 11:10:25 -0700 Subject: [PATCH 297/428] core: only adjust selection on keypress --- TODO.md | 1 + src/Surface.zig | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index aef8aa94d6..08a4280007 100644 --- a/TODO.md +++ b/TODO.md @@ -25,3 +25,4 @@ paged-terminal branch: - tests and logic for overflowing page capacities: * graphemes +- there is some bug around adjusting selection up out of viewport diff --git a/src/Surface.zig b/src/Surface.zig index 857055c776..2418a535ca 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1360,6 +1360,11 @@ pub fn keyCallback( defer self.renderer_state.mutex.unlock(); var screen = self.io.terminal.screen; const sel = if (screen.selection) |*sel| sel else break :adjust_selection; + + // Silently consume key releases. We only want to process selection + // adjust on press. + if (event.action != .press and event.action != .repeat) return .consumed; + sel.adjust(&screen, switch (event.key) { .left => .left, .right => .right, @@ -1372,9 +1377,6 @@ pub fn keyCallback( else => break :adjust_selection, }); - // Silently consume key releases. - if (event.action != .press and event.action != .repeat) return .consumed; - // If the selection endpoint is outside of the current viewpoint, // scroll it in to view. // TODO(paged-terminal) From 44d320a23e731bc84ee509907f87f42ef312f8b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 12:36:23 -0700 Subject: [PATCH 298/428] terminal: selectionString should use proper ordered --- src/terminal/Screen.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 50ff93fe27..fd295fc9c6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1058,13 +1058,13 @@ pub fn selectionString( const sel_ordered = sel.ordered(self, .forward); const sel_start = start: { - var start = sel.start(); + var start = sel_ordered.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(); + var end = sel_ordered.end(); const cell = end.rowAndCell().cell; switch (cell.wide) { .narrow, .wide => {}, From a3509f32a9d6a3e6cb9557107f61acf96d2d73d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 12:48:24 -0700 Subject: [PATCH 299/428] terminal: selection should use pin iterators --- src/terminal/Selection.zig | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 7343ddd767..277045981d 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -382,11 +382,7 @@ pub fn adjust( }, .left => { - var it = s.pages.cellIterator( - .left_up, - .{ .screen = .{} }, - s.pages.pointFromPin(.screen, end_pin.*).?, - ); + var it = end_pin.cellIterator(.left_up, null); _ = it.next(); while (it.next()) |next| { const rac = next.rowAndCell(); @@ -400,11 +396,7 @@ pub fn adjust( .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, - ); + var it = end_pin.cellIterator(.right_down, null); _ = it.next(); while (it.next()) |next| { const rac = next.rowAndCell(); From 9351cab038999b321cc3aa535b74403d4de25451 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:05:53 -0700 Subject: [PATCH 300/428] terminal: Screen clone should preserve selection order --- src/terminal/Screen.zig | 58 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fd295fc9c6..04db073a61 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -263,12 +263,28 @@ pub fn clonePool( // Preserve our selection if we have one. const sel: ?Selection = if (self.selection) |sel| sel: { assert(sel.tracked()); - const start_pin = pin_remap.get(sel.bounds.tracked.start) orelse start: { + + const ordered: struct { + tl: *Pin, + br: *Pin, + } = switch (sel.order(self)) { + .forward, .mirrored_forward => .{ + .tl = sel.bounds.tracked.start, + .br = sel.bounds.tracked.end, + }, + .reverse, .mirrored_reverse => .{ + .tl = sel.bounds.tracked.end, + .br = sel.bounds.tracked.start, + }, + }; + + const start_pin = pin_remap.get(ordered.tl) orelse start: { // No start means it is outside the cloned area. We change it // to the top-left. break :start try pages.trackPin(.{ .page = pages.pages.first.? }); }; - const end_pin = pin_remap.get(sel.bounds.tracked.end) orelse end: { + + const end_pin = pin_remap.get(ordered.br) orelse end: { // No end means it is outside the cloned area. We change it // to the bottom-right. break :end try pages.trackPin(pages.pin(.{ .active = .{ @@ -276,6 +292,7 @@ pub fn clonePool( .y = pages.rows - 1, } }) orelse break :sel null); }; + break :sel .{ .bounds = .{ .tracked = .{ .start = start_pin, @@ -2877,6 +2894,43 @@ test "Screen: clone contains selection end cutoff" { } } +test "Screen: clone contains selection end cutoff reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 1 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s2.pages.cols - 1, + .y = 2, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; From 7ae9b0c4691a377100feb35dacae44619a82af32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:09:11 -0700 Subject: [PATCH 301/428] terminal: screen clone that doesn't have sel should set null sel --- src/terminal/Screen.zig | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 04db073a61..fe672bb775 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -279,8 +279,12 @@ pub fn clonePool( }; const start_pin = pin_remap.get(ordered.tl) orelse start: { + // No start means it is outside the cloned area. We change it - // to the top-left. + // to the top-left. If we have no end pin then our whole + // selection is outside the cloned area so we can just set it + // as null. + if (pin_remap.get(ordered.br) == null) break :sel null; break :start try pages.trackPin(.{ .page = pages.pages.first.? }); }; @@ -2820,6 +2824,33 @@ test "Screen: clone contains full selection" { } } +test "Screen: clone contains none of selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Select a single line + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 0 } }).?, + false, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + null, + ); + defer s2.deinit(); + + // Our selection should be null + try testing.expect(s2.selection == null); +} + test "Screen: clone contains selection start cutoff" { const testing = std.testing; const alloc = testing.allocator; From 5b2f624c0acdf6168ee46d5422e1381c45373b64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:09:35 -0700 Subject: [PATCH 302/428] update TODO --- TODO.md | 1 - 1 file changed, 1 deletion(-) diff --git a/TODO.md b/TODO.md index 08a4280007..aef8aa94d6 100644 --- a/TODO.md +++ b/TODO.md @@ -25,4 +25,3 @@ paged-terminal branch: - tests and logic for overflowing page capacities: * graphemes -- there is some bug around adjusting selection up out of viewport From 522c28207e84eb338667fd76ba32d5440aa7a2a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:10:10 -0700 Subject: [PATCH 303/428] terminal: remove TODO --- src/terminal/Selection.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 277045981d..9da0f134ef 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -413,7 +413,6 @@ pub fn adjust( 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 { From 935063d8924c070ff83415a3eed33117f4a7c70c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:23:55 -0700 Subject: [PATCH 304/428] core: scroll to selection working --- src/Surface.zig | 36 ++++++++++++++++++++---------------- src/terminal/PageList.zig | 13 +++++++++++++ src/terminal/Screen.zig | 2 ++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2418a535ca..6ffc001e9c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1358,14 +1358,14 @@ pub fn keyCallback( if (event.mods.shift) adjust_selection: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - var screen = self.io.terminal.screen; + var screen = &self.io.terminal.screen; const sel = if (screen.selection) |*sel| sel else break :adjust_selection; // Silently consume key releases. We only want to process selection // adjust on press. if (event.action != .press and event.action != .repeat) return .consumed; - sel.adjust(&screen, switch (event.key) { + sel.adjust(screen, switch (event.key) { .left => .left, .right => .right, .up => .up, @@ -1378,20 +1378,24 @@ pub fn keyCallback( }); // If the selection endpoint is outside of the current viewpoint, - // scroll it in to view. - // TODO(paged-terminal) - // scroll: { - // const viewport_max = terminal.Screen.RowIndexTag.viewport.maxLen(&screen) - 1; - // const viewport_end = screen.viewport + viewport_max; - // const delta: isize = if (sel.end.y < screen.viewport) - // @intCast(screen.viewport) - // else if (sel.end.y > viewport_end) - // @intCast(viewport_end) - // else - // break :scroll; - // const start_y: isize = @intCast(sel.end.y); - // try self.io.terminal.scrollViewport(.{ .delta = start_y - delta }); - // } + // scroll it in to view. Note we always specifically use sel.end + // because that is what adjust modifies. + scroll: { + const viewport_tl = screen.pages.getTopLeft(.viewport); + const viewport_br = screen.pages.getBottomRight(.viewport).?; + if (sel.end().isBetween(viewport_tl, viewport_br)) + break :scroll; + + // Our end point is not within the viewport. If the end + // point is after the br then we need to adjust the end so + // that it is at the bottom right of the viewport. + const target = if (sel.end().before(viewport_tl)) + sel.end() + else + sel.end().up(screen.pages.rows - 1) orelse sel.end(); + + screen.scroll(.{ .pin = target }); + } // Queue a render so its shown try self.queueRender(); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 96a12513cc..f0fa6168d0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1413,6 +1413,10 @@ pub const Scroll = union(enum) { /// prompts. If the absolute value is greater than the number of prompts /// in either direction, jump to the furthest prompt in that direction. delta_prompt: isize, + + /// Scroll directly to a specific pin in the page. This will be set + /// as the top left of the viewport (ignoring the pin x value). + pin: Pin, }; /// Scroll the viewport. This will never create new scrollback, allocate @@ -1422,6 +1426,15 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { .active => self.viewport = .{ .active = {} }, .top => self.viewport = .{ .top = {} }, + .pin => |p| { + if (self.pinIsActive(p)) { + self.viewport = .{ .active = {} }; + return; + } + + self.viewport_pin.* = p; + self.viewport = .{ .pin = {} }; + }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| { if (n == 0) return; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fe672bb775..571fd15418 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -552,6 +552,7 @@ pub const Scroll = union(enum) { /// For all of these, see PageList.Scroll. active, top, + pin: Pin, delta_row: isize, delta_prompt: isize, }; @@ -566,6 +567,7 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { switch (behavior) { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), + .pin => |p| self.pages.scroll(.{ .pin = p }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } From d1faa37b659177f649758f31e826015c93a73fb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:36:49 -0700 Subject: [PATCH 305/428] renderer/opengl: convert --- src/renderer/Metal.zig | 1 - src/renderer/OpenGL.zig | 240 ++++++++++++++++------------------------ 2 files changed, 93 insertions(+), 148 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 801c8bd96e..0d546b7a75 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1254,7 +1254,6 @@ fn prepKittyGraphics( const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - std.log.warn("vp_y={} img_y={}", .{ vp_y, img_y }); const offset_cells = vp_y - img_y; const offset_pixels = offset_cells * self.grid_metrics.cell_height; break :offset_y @intCast(offset_pixels); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 77d42dc8f7..fab2ae37ec 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -657,7 +657,6 @@ pub fn updateFrame( // Data we extract out of the critical area. const Critical = struct { gl_bg: terminal.color.RGB, - selection: ?terminal.Selection, screen: terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, @@ -691,25 +690,13 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to // hold the lock wile rebuilding GPU cells. - const viewport_bottom = state.terminal.screen.viewportIsBottom(); - var screen_copy = if (viewport_bottom) try state.terminal.screen.clone( + var screen_copy = try state.terminal.screen.clone( self.alloc, - .{ .active = 0 }, - .{ .active = state.terminal.rows - 1 }, - ) else try state.terminal.screen.clone( - self.alloc, - .{ .viewport = 0 }, - .{ .viewport = state.terminal.rows - 1 }, + .{ .viewport = .{} }, + null, ); errdefer screen_copy.deinit(); - // Convert our selection to viewport points because we copy only - // the viewport above. - const selection: ?terminal.Selection = if (state.terminal.screen.selection) |sel| - sel.toViewport(&state.terminal.screen) - else - null; - // Whether to draw our cursor or not. const cursor_style = renderer.cursorStyle( state, @@ -739,7 +726,6 @@ pub fn updateFrame( break :critical .{ .gl_bg = self.background_color, - .selection = selection, .screen = screen_copy, .mouse = state.mouse, .preedit = preedit, @@ -762,7 +748,6 @@ pub fn updateFrame( // Build our GPU cells try self.rebuildCells( - critical.selection, &critical.screen, critical.mouse, critical.preedit, @@ -802,11 +787,8 @@ fn prepKittyGraphics( // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = (terminal.point.Viewport{}).toScreen(&t.screen); - const bot = (terminal.point.Viewport{ - .x = t.screen.cols - 1, - .y = t.screen.rows - 1, - }).toScreen(&t.screen); + const top = t.screen.pages.getTopLeft(.viewport); + const bot = t.screen.pages.getBottomRight(.viewport).?; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -823,13 +805,15 @@ fn prepKittyGraphics( // If the selection isn't within our viewport then skip it. const rect = p.rect(image, t); - if (rect.top_left.y > bot.y) continue; - if (rect.bottom_right.y < top.y) continue; + if (bot.before(rect.top_left)) continue; + if (rect.bottom_right.before(top)) continue; // If the top left is outside the viewport we need to calc an offset // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.y < t.screen.viewport) offset_y: { - const offset_cells = t.screen.viewport - rect.top_left.y; + const offset_y: u32 = if (rect.top_left.before(top)) offset_y: { + const vp_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const offset_cells = vp_y - img_y; const offset_pixels = offset_cells * self.grid_metrics.cell_height; break :offset_y @intCast(offset_pixels); } else 0; @@ -875,7 +859,10 @@ fn prepKittyGraphics( } // Convert our screen point to a viewport point - const viewport = p.point.toViewport(&t.screen); + const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + .viewport, + p.pin.*, + ) orelse .{ .viewport = .{} }; // Calculate the source rectangle const source_x = @min(image.width, p.source_x); @@ -897,8 +884,8 @@ fn prepKittyGraphics( if (image.width > 0 and image.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = kv.key_ptr.image_id, - .x = @intCast(p.point.x), - .y = @intCast(viewport.y), + .x = @intCast(p.pin.x), + .y = @intCast(viewport.viewport.y), .z = p.z, .width = dest_width, .height = dest_height, @@ -955,16 +942,21 @@ fn prepKittyGraphics( /// the renderer will do this when it needs more memory space. pub fn rebuildCells( self: *OpenGL, - term_selection: ?terminal.Selection, screen: *terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, ) !void { + const rows_usize: usize = @intCast(screen.pages.rows); + const cols_usize: usize = @intCast(screen.pages.cols); + // Bg cells at most will need space for the visible screen size self.cells_bg.clearRetainingCapacity(); - try self.cells_bg.ensureTotalCapacity(self.alloc, screen.rows * screen.cols); + try self.cells_bg.ensureTotalCapacity( + self.alloc, + rows_usize * cols_usize, + ); // For now, we just ensure that we have enough cells for all the lines // we have plus a full width. This is very likely too much but its @@ -975,24 +967,27 @@ pub fn rebuildCells( // * 3 for glyph + underline + strikethrough for each cell // + 1 for cursor - (screen.rows * screen.cols * 3) + 1, + (rows_usize * cols_usize * 3) + 1, ); // Create an arena for all our temporary allocations while rebuilding var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); const arena_alloc = arena.allocator(); + _ = arena_alloc; + _ = mouse; // We've written no data to the GPU, refresh it all self.gl_cells_written = 0; // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; + // TODO(paged-terminal) + // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + // arena_alloc, + // screen, + // mouse_pt, + // mouse.mods, + // ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -1001,7 +996,7 @@ pub fn rebuildCells( x: [2]usize, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.cols - 1); + const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); break :preedit .{ .y = screen.cursor.y, .x = .{ range.start, range.end }, @@ -1015,9 +1010,9 @@ pub fn rebuildCells( var cursor_cell: ?CellProgram.Cell = null; // Build each cell - var rowIter = screen.rowIterator(.viewport); + var row_it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var y: usize = 0; - while (rowIter.next()) |row| { + while (row_it.next()) |row| { defer y += 1; // Our selection value is only non-null if this selection happens @@ -1025,19 +1020,10 @@ pub fn rebuildCells( // the selection that contains this row. This way, if the selection // changes but not for this line, we don't invalidate the cache. const selection = sel: { - if (term_selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = 0, - .y = y, - }).toScreen(screen); - - // If we are selected, we our colors are just inverted fg/bg. - if (sel.containedRow(screen, screen_point)) |row_sel| { - break :sel row_sel; - } - } - - break :sel null; + const sel = screen.selection orelse break :sel null; + const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse + break :sel null; + break :sel sel.containedRow(screen, pin) orelse null; }; // See Metal.zig @@ -1059,8 +1045,8 @@ pub fn rebuildCells( defer if (cursor_row) { // If we're on a wide spacer tail, then we want to look for // the previous cell. - const screen_cell = row.getCell(screen.cursor.x); - const x = screen.cursor.x - @intFromBool(screen_cell.attrs.wide_spacer_tail); + const screen_cell = row.cells(.all)[screen.cursor.x]; + const x = screen.cursor.x - @intFromBool(screen_cell.wide == .spacer_tail); for (self.cells.items[start_i..]) |cell| { if (cell.grid_col == x and (cell.mode == .fg or cell.mode == .fg_color)) @@ -1074,6 +1060,7 @@ pub fn rebuildCells( // Split our row into runs and shape each one. var iter = self.font_shaper.runIterator( self.font_group, + screen, row, selection, if (shape_cursor) screen.cursor.x else null, @@ -1094,23 +1081,25 @@ pub fn rebuildCells( // It this cell is within our hint range then we need to // underline it. - const cell: terminal.Screen.Cell = cell: { - var cell = row.getCell(shaper_cell.x); - - // If our links contain this cell then we want to - // underline it. - if (link_match_set.orderedContains(.{ - .x = shaper_cell.x, - .y = y, - })) { - cell.attrs.underline = .single; - } - - break :cell cell; + const cell: terminal.Pin = cell: { + var copy = row; + copy.x = shaper_cell.x; + break :cell copy; + + // TODO(paged-terminal) + // // If our links contain this cell then we want to + // // underline it. + // if (link_match_set.orderedContains(.{ + // .x = shaper_cell.x, + // .y = y, + // })) { + // cell.attrs.underline = .single; + // } + // + // break :cell cell; }; if (self.updateCell( - term_selection, screen, cell, color_palette, @@ -1129,9 +1118,6 @@ pub fn rebuildCells( } } } - - // Set row is not dirty anymore - row.setDirty(false); } // Add the cursor at the end so that it overlays everything. If we have @@ -1277,21 +1263,14 @@ fn addCursor( // we're on the wide characer tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x, - ); - if (!cell.attrs.wide_spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.attrs.wide, screen.cursor.x }; + const cell = screen.cursor.page_cell; + if (cell.wide != .spacer_tail or screen.cursor.x == 0) + break :cell .{ cell.wide == .wide, screen.cursor.x }; // If we're part of a wide character, we move the cursor back to // the actual character. - break :cell .{ screen.getCell( - .active, - screen.cursor.y, - screen.cursor.x - 1, - ).attrs.wide, screen.cursor.x - 1 }; + const prev_cell = screen.cursorCellLeft(1); + break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; }; const color = self.cursor_color orelse self.foreground_color; @@ -1349,9 +1328,8 @@ fn addCursor( /// needed. fn updateCell( self: *OpenGL, - selection: ?terminal.Selection, screen: *terminal.Screen, - cell: terminal.Screen.Cell, + cell_pin: terminal.Pin, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1371,47 +1349,29 @@ fn updateCell( }; // True if this cell is selected - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - const selected: bool = if (selection) |sel| selected: { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); + const selected: bool = if (screen.selection) |sel| + sel.contains(screen, cell_pin) + else + false; - break :selected sel.contains(screen_point); - } else false; + const rac = cell_pin.rowAndCell(); + const cell = rac.cell; + const style = cell_pin.style(cell); // The colors for the cell. const colors: BgFg = colors: { // The normal cell result - const cell_res: BgFg = if (!cell.attrs.inverse) .{ + const cell_res: BgFg = if (!style.flags.inverse) .{ // In normal mode, background and fg match the cell. We // un-optionalize the fg by defaulting to our fg color. - .bg = switch (cell.bg) { - .none => null, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.bg(cell, palette), + .fg = style.fg(palette) orelse self.foreground_color, } else .{ // In inverted mode, the background MUST be set to something // (is never null) so it is either the fg or default fg. The // fg is either the bg or default background. - .bg = switch (cell.fg) { - .none => self.foreground_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, - .fg = switch (cell.bg) { - .none => self.background_color, - .indexed => |i| palette[i], - .rgb => |rgb| rgb, - }, + .bg = style.fg(palette) orelse self.foreground_color, + .fg = style.bg(cell, palette) orelse self.background_color, }; // If we are selected, we our colors are just inverted fg/bg @@ -1429,7 +1389,7 @@ fn updateCell( // If the cell is "invisible" then we just make fg = bg so that // the cell is transparent but still copy-able. const res: BgFg = selection_res orelse cell_res; - if (cell.attrs.invisible) { + if (style.flags.invisible) { break :colors BgFg{ .bg = res.bg, .fg = res.bg orelse self.background_color, @@ -1439,19 +1399,8 @@ fn updateCell( break :colors res; }; - // Calculate the amount of space we need in the cells list. - const needed = needed: { - var i: usize = 0; - if (colors.bg != null) i += 1; - if (!cell.empty()) i += 1; - if (cell.attrs.underline != .none) i += 1; - if (cell.attrs.strikethrough) i += 1; - break :needed i; - }; - if (self.cells.items.len + needed > self.cells.capacity) return false; - // Alpha multiplier - const alpha: u8 = if (cell.attrs.faint) 175 else 255; + const alpha: u8 = if (style.flags.faint) 175 else 255; // If the cell has a background, we always draw it. const bg: [4]u8 = if (colors.bg) |rgb| bg: { @@ -1468,11 +1417,11 @@ fn updateCell( if (selected) break :bg_alpha default; // If we're reversed, do not apply background opacity - if (cell.attrs.inverse) break :bg_alpha default; + if (style.flags.inverse) break :bg_alpha default; // If we have a background and its not the default background // then we apply background opacity - if (cell.bg != .none and !rgb.eql(self.background_color)) { + if (style.bg(cell, palette) != null and !rgb.eql(self.background_color)) { break :bg_alpha default; } @@ -1487,7 +1436,7 @@ fn updateCell( .mode = .bg, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = 0, .glyph_y = 0, .glyph_width = 0, @@ -1513,7 +1462,7 @@ fn updateCell( }; // If the cell has a character, draw it - if (cell.char > 0) fg: { + if (cell.hasText()) fg: { // Render const glyph = try self.font_group.renderGlyph( self.alloc, @@ -1528,11 +1477,8 @@ fn updateCell( // If we're rendering a color font, we use the color atlas const mode: CellProgram.CellMode = switch (try fgMode( &self.font_group.group, - screen, - cell, + cell_pin, shaper_run, - x, - y, )) { .normal => .fg, .color => .fg_color, @@ -1543,7 +1489,7 @@ fn updateCell( .mode = mode, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = glyph.atlas_x, .glyph_y = glyph.atlas_y, .glyph_width = glyph.width, @@ -1561,8 +1507,8 @@ fn updateCell( }); } - if (cell.attrs.underline != .none) { - const sprite: font.Sprite = switch (cell.attrs.underline) { + if (style.flags.underline != .none) { + const sprite: font.Sprite = switch (style.flags.underline) { .none => unreachable, .single => .underline, .double => .underline_double, @@ -1577,17 +1523,17 @@ fn updateCell( @intFromEnum(sprite), .{ .grid_metrics = self.grid_metrics, - .cell_width = if (cell.attrs.wide) 2 else 1, + .cell_width = if (cell.wide == .wide) 2 else 1, }, ); - const color = if (cell.attrs.underline_color) cell.underline_fg else colors.fg; + const color = style.underlineColor(palette) orelse colors.fg; self.cells.appendAssumeCapacity(.{ .mode = .fg, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = underline_glyph.atlas_x, .glyph_y = underline_glyph.atlas_y, .glyph_width = underline_glyph.width, @@ -1605,12 +1551,12 @@ fn updateCell( }); } - if (cell.attrs.strikethrough) { + if (style.flags.strikethrough) { self.cells.appendAssumeCapacity(.{ .mode = .strikethrough, .grid_col = @intCast(x), .grid_row = @intCast(y), - .grid_width = cell.widthLegacy(), + .grid_width = cell.gridWidth(), .glyph_x = 0, .glyph_y = 0, .glyph_width = 0, From 5c7460a741dc7a972b0409f9f3cee4e3d3097c9a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 13:41:12 -0700 Subject: [PATCH 306/428] prettier --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index aef8aa94d6..a6cfb4c8ba 100644 --- a/TODO.md +++ b/TODO.md @@ -24,4 +24,4 @@ Major Features: paged-terminal branch: - tests and logic for overflowing page capacities: - * graphemes + - graphemes From 2fe68eb9734e35fa720730b8f6c2f35a26be556f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 17:30:15 -0700 Subject: [PATCH 307/428] terminal: bitmap allocator had off by one on extra bitmaps --- src/terminal-old/modes.zig | 2 +- src/terminal/bitmap_allocator.zig | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/terminal-old/modes.zig b/src/terminal-old/modes.zig index e42efa16e7..c9ed84cbde 100644 --- a/src/terminal-old/modes.zig +++ b/src/terminal-old/modes.zig @@ -89,7 +89,7 @@ pub const ModePacked = packed_struct: { } break :packed_struct @Type(.{ .Struct = .{ - .layout = .Packed, + .layout = .@"packed", .fields = &fields, .decls = &.{}, .is_tuple = false, diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 242575f9f5..9d45327cb4 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -76,6 +76,7 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { // fixed but we haven't needed it. Contributor friendly: add tests // and fix this. assert(chunk_size % @alignOf(T) == 0); + assert(n > 0); const byte_count = std.math.mul(usize, @sizeOf(T), n) catch return error.OutOfMemory; @@ -193,7 +194,7 @@ fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { bitmap.* ^= mask; } - return (idx * 63) + bit; + return (idx * 64) + bit; } return null; @@ -229,7 +230,7 @@ test "findFreeChunks multiple found" { 0b10000000_00111110_00000000_00000000_00000000_00000000_00111110_00000000, }; const idx = findFreeChunks(&bitmaps, 4).?; - try testing.expectEqual(@as(usize, 72), idx); + try testing.expectEqual(@as(usize, 73), idx); try testing.expectEqual( 0b10000000_00111110_00000000_00000000_00000000_00000000_00100000_00000000, bitmaps[1], From c0ed1fa370c31fec857ea11d18cbc6c820faed06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 20:31:32 -0700 Subject: [PATCH 308/428] terminal: pagelist can adjust grapheme byte capacity --- src/terminal/PageList.zig | 51 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f0fa6168d0..ce15faf057 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1596,6 +1596,9 @@ pub const AdjustCapacity = struct { /// rounded up if necessary to fit alignment requirements, /// but it will never be rounded down. styles: ?u16 = null, + + /// Adjust the number of available grapheme bytes in the page. + grapheme_bytes: ?usize = null, }; /// Adjust the capcaity of the given page in the list. This should @@ -1623,11 +1626,14 @@ pub fn adjustCapacity( // ensures we never shrink from what we need. var cap = page.data.capacity; - // From there, we increase our capacity as required if (adjustment.styles) |v| { const aligned = try std.math.ceilPowerOfTwo(u16, v); cap.styles = @max(cap.styles, aligned); } + if (adjustment.grapheme_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); + } log.info("adjusting page capacity={}", .{cap}); @@ -3318,6 +3324,49 @@ test "PageList adjustCapacity to increase styles" { } } +test "PageList adjustCapacity to increase graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our graphemes + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .grapheme_bytes = std_capacity.grapheme_bytes * 2 }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; From a59d4286c752e33e1ccf951c254757f2ceb63181 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Mar 2024 20:54:20 -0700 Subject: [PATCH 309/428] terminal: adjust page capacity for graphemes if necessary --- TODO.md | 5 ----- src/terminal/Screen.zig | 42 +++++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 23 +++++++++++--------- src/terminal/page.zig | 2 +- src/terminal/res/glitch.txt | 1 + 5 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 src/terminal/res/glitch.txt diff --git a/TODO.md b/TODO.md index a6cfb4c8ba..8e7d958242 100644 --- a/TODO.md +++ b/TODO.md @@ -20,8 +20,3 @@ Major Features: - Bell - Sixels: https://saitoha.github.io/libsixel/ - -paged-terminal branch: - -- tests and logic for overflowing page capacities: - - graphemes diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 571fd15418..9e7f99e65f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1033,6 +1033,48 @@ pub fn manualStyleUpdate(self: *Screen) !void { self.cursor.style_ref = &md.ref; } +/// Append a grapheme to the given cell within the current cursor row. +pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { + self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + cell, + cp, + ) catch |err| switch (err) { + error.OutOfMemory => { + // We need to determine the actual cell index of the cell so + // that after we adjust the capacity we can reload the cell. + const cell_idx: usize = cell_idx: { + const cells: [*]Cell = @ptrCast(self.cursor.page_cell); + const zero: [*]Cell = cells - self.cursor.x; + const target: [*]Cell = @ptrCast(cell); + const cell_idx = (@intFromPtr(target) - @intFromPtr(zero)) / @sizeOf(Cell); + break :cell_idx cell_idx; + }; + + // Adjust our capacity. This will update our cursor page pin and + // force us to reload. + const original_node = self.cursor.page_pin.page; + const new_bytes = original_node.data.capacity.grapheme_bytes * 2; + _ = try self.pages.adjustCapacity(original_node, .{ .grapheme_bytes = new_bytes }); + self.cursorReload(); + + // The cell pointer is now invalid, so we need to get it from + // the reloaded cursor pointers. + const reloaded_cell: *Cell = switch (std.math.order(cell_idx, self.cursor.x)) { + .eq => self.cursor.page_cell, + .lt => self.cursorCellLeft(@intCast(self.cursor.x - cell_idx)), + .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), + }; + + try self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + reloaded_cell, + cp, + ); + }, + }; +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take overnship of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f7e2b333f0..ece11342fa 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -351,11 +351,7 @@ pub fn print(self: *Terminal, c: u21) !void { } 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, - ); + try self.screen.appendGrapheme(prev.cell, c); return; } } @@ -408,11 +404,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (!emoji) return; } - try self.screen.cursor.page_pin.page.data.appendGrapheme( - self.screen.cursor.page_row, - prev, - c, - ); + try self.screen.appendGrapheme(prev, c); return; } @@ -2299,6 +2291,17 @@ test "Terminal: input unique style per cell" { } } +test "Terminal: input glitch text" { + const glitch = @embedFile("res/glitch.txt"); + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); + + for (0..100) |_| { + try t.printString(glitch); + } +} + test "Terminal: zero-width character at start" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 591a903df0..7424d18631 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -373,7 +373,7 @@ pub const Page = struct { } /// Append a codepoint to the given cell as a grapheme. - pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) !void { + pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { if (comptime std.debug.runtime_safety) assert(cell.hasText()); const cell_offset = getOffset(Cell, self.memory, cell); diff --git a/src/terminal/res/glitch.txt b/src/terminal/res/glitch.txt new file mode 100644 index 0000000000..f401c08800 --- /dev/null +++ b/src/terminal/res/glitch.txt @@ -0,0 +1 @@ +Ḡ̴͎͍̜͎͔͕̩̗͕͖̟̜͑̊̌̇̑͒͋͑̄̈́͐̈́́̽͌͂̎̀̔͋̓́̅̌̇͘̕͜͝͝h̶̡̞̫͉̳̬̜̱̥͕͑̾͛̒̆̒̉̒̑͂̄͘ͅǫ̷̨̥͔͔͖̭͚͙̯̟̭̘͇̫̰͚̺̳̙̳̟͚̫̱̹̱͒̂̑͒͜͠ͅş̴̖̰̜̱̹͙̅͒̀̏͆̐̋͂̓͋̃̈̔̂̈͛̐̿̔́̔̄͑̇͑̋̈́͌͋̾̃̽̈́̕͘̚͘͘͘͠͠t̵̢̜̱̦͇͉̬̮̼͖̳̗̥̝̬͇͕̥̜͕̳̱̥̮͉̮̩̘̰̪̤͉͎̲͈͍̳̟̠͈̝̫͋̊̀͐̍̅̀̄̃̈́̔̇̈́̄̃̽̂̌̅̄̋͒̃̈́̍̀̍̇̽̐͊̾̆̅̈̿̓͒̄̾͌̚͝͝͝͝͝t̴̥̼̳̗̬̬͔͎̯͉͇̮̰͖͇̝͔̳̳̗̰͇͎͉̬͇̝̺̯͎͖͔̍͆͒̊̒̔̊̈́̿̊̅͂̐͋̿͂̈̒̄͜͠͠ÿ̴̢̗̜̥͇͖̰͎̝̹̗̪̙̞̣̳͎̯̹͚̲̝̗̳̳̗̖͎̗̬͈͙̝̟͍̥̤͖͇̰͈̺͛̒̂͌̌̏̈̾̓̈́̿͐̂̓̔̓̂̈́͑͛͊͋̔̿̊͑͌̊̏͘͘̕͘͠͝ From 9015b7548f91d86add837f4368378dd39e82d0a9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 10:00:50 -0700 Subject: [PATCH 310/428] inspector: support cell pinning again --- src/Surface.zig | 26 ++++++++++++------------- src/inspector/Inspector.zig | 38 ++++++++++++++++++++++++++++++++++--- src/inspector/main.zig | 3 +++ src/terminal/main.zig | 3 ++- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6ffc001e9c..573f131e06 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2143,19 +2143,19 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - // TODO(paged-terminal) - // const cell = self.renderer_state.terminal.screen.getCell( - // .viewport, - // point.y, - // point.x, - // ); - - insp.cell = .{ - .selected = .{ - .row = point.y, - .col = point.x, - //.cell = cell, - }, + const screen = &self.renderer_state.terminal.screen; + const p = screen.pages.pin(.{ .active = point }) orelse { + log.warn("failed to get pin for clicked point", .{}); + return; + }; + + insp.cell.select( + self.alloc, + p, + point.x, + point.y, + ) catch |err| { + log.warn("error selecting cell for inspector err={}", .{err}); }; return; } diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 4262dccb4b..446dcee112 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -4,6 +4,7 @@ const Inspector = @This(); const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const cimgui = @import("cimgui"); @@ -62,18 +63,47 @@ const CellInspect = union(enum) { selected: Selected, const Selected = struct { + alloc: Allocator, row: usize, col: usize, - // TODO(paged-terminal) - //cell: terminal.Screen.Cell, + cell: inspector.Cell, }; + pub fn deinit(self: *CellInspect) void { + switch (self.*) { + .idle, .requested => {}, + .selected => |*v| v.cell.deinit(v.alloc), + } + } + pub fn request(self: *CellInspect) void { switch (self.*) { - .idle, .selected => self.* = .requested, + .idle => self.* = .requested, + .selected => |*v| { + v.cell.deinit(v.alloc); + self.* = .requested; + }, .requested => {}, } } + + pub fn select( + self: *CellInspect, + alloc: Allocator, + pin: terminal.Pin, + x: usize, + y: usize, + ) !void { + assert(self.* == .requested); + const cell = try inspector.Cell.init(alloc, pin); + errdefer cell.deinit(alloc); + self.* = .{ .selected = .{ + .alloc = alloc, + .row = y, + .col = x, + .cell = cell, + } }; + } }; /// Setup the ImGui state. This requires an ImGui context to be set. @@ -136,6 +166,8 @@ pub fn init(surface: *Surface) !Inspector { } pub fn deinit(self: *Inspector) void { + self.cell.deinit(); + { var it = self.key_events.iterator(.forward); while (it.next()) |v| v.deinit(self.surface.alloc); diff --git a/src/inspector/main.zig b/src/inspector/main.zig index 920491dd8b..c80384182c 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,7 +1,10 @@ const std = @import("std"); +pub const cell = @import("cell.zig"); pub const cursor = @import("cursor.zig"); pub const key = @import("key.zig"); pub const termio = @import("termio.zig"); + +pub const Cell = cell.Cell; pub const Inspector = @import("Inspector.zig"); test { diff --git a/src/terminal/main.zig b/src/terminal/main.zig index c0863f1f4f..ea2e65240b 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -7,6 +7,7 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); +const style = @import("style.zig"); pub const apc = @import("apc.zig"); pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); @@ -34,6 +35,7 @@ pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); +pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; @@ -57,5 +59,4 @@ test { _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); _ = @import("size.zig"); - _ = @import("style.zig"); } From 62932f36316c7239e5ee8e1c5a061533ae3d1f2c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 10:16:34 -0700 Subject: [PATCH 311/428] inspector: cell selection works again --- src/inspector/Inspector.zig | 221 ++++++++---------------------------- src/inspector/cursor.zig | 62 ++++++---- 2 files changed, 86 insertions(+), 197 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 446dcee112..fd7363f90f 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -211,9 +211,6 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { - // TODO(paged-terminal) - if (true) return; - const dock_id = cimgui.c.igDockSpaceOverViewport( cimgui.c.igGetMainViewport(), cimgui.c.ImGuiDockNodeFlags_None, @@ -335,9 +332,10 @@ fn renderScreenWindow(self: *Inspector) void { 0, ); defer cimgui.c.igEndTable(); - - const palette = self.surface.io.terminal.color_palette.colors; - inspector.cursor.renderInTable(&screen.cursor, &palette); + inspector.cursor.renderInTable( + self.surface.renderer_state.terminal, + &screen.cursor, + ); } // table cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); @@ -698,24 +696,25 @@ fn renderSizeWindow(self: *Inspector) void { defer cimgui.c.igEndTable(); const mouse = &self.surface.mouse; - const t = self.surface.renderer_state.terminal; - - { - const hover_point = self.mouse.last_point.toViewport(&t.screen); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Hover Grid"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( - "row=%d, col=%d", - hover_point.y, - hover_point.x, - ); - } - } + //const t = self.surface.renderer_state.terminal; + + // TODO(paged-terminal) + // { + // const hover_point = self.mouse.last_point.toViewport(&t.screen); + // cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + // { + // _ = cimgui.c.igTableSetColumnIndex(0); + // cimgui.c.igText("Hover Grid"); + // } + // { + // _ = cimgui.c.igTableSetColumnIndex(1); + // cimgui.c.igText( + // "row=%d, col=%d", + // hover_point.y, + // hover_point.x, + // ); + // } + // } { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); @@ -772,22 +771,23 @@ fn renderSizeWindow(self: *Inspector) void { } } - { - const left_click_point = mouse.left_click_point.toViewport(&t.screen); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Click Grid"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( - "row=%d, col=%d", - left_click_point.y, - left_click_point.x, - ); - } - } + // TODO(paged-terminal) + // { + // const left_click_point = mouse.left_click_point.toViewport(&t.screen); + // cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + // { + // _ = cimgui.c.igTableSetColumnIndex(0); + // cimgui.c.igText("Click Grid"); + // } + // { + // _ = cimgui.c.igTableSetColumnIndex(1); + // cimgui.c.igText( + // "row=%d, col=%d", + // left_click_point.y, + // left_click_point.x, + // ); + // } + // } { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); @@ -862,136 +862,11 @@ fn renderCellWindow(self: *Inspector) void { } const selected = self.cell.selected; - - { - // We have a selected cell, show information about it. - _ = cimgui.c.igBeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, - ); - defer cimgui.c.igEndTable(); - - { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grid Position"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("row=%d col=%d", selected.row, selected.col); - } - } - - // NOTE: we don't currently write the character itself because - // we haven't hooked up imgui to our font system. That's hard! We - // can/should instead hook up our renderer to imgui and just render - // the single glyph in an image view so it looks _identical_ to the - // terminal. - codepoint: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Codepoint"); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - if (selected.cell.char == 0) { - cimgui.c.igTextDisabled("(empty)"); - break :codepoint; - } - - cimgui.c.igText("U+%X", selected.cell.char); - } - } - - // If we have a color then we show the color - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Foreground Color"); - _ = cimgui.c.igTableSetColumnIndex(1); - switch (selected.cell.fg) { - .none => cimgui.c.igText("default"), - else => { - const rgb = switch (selected.cell.fg) { - .none => unreachable, - .indexed => |idx| self.surface.io.terminal.color_palette.colors[idx], - .rgb => |rgb| rgb, - }; - - if (selected.cell.fg == .indexed) { - cimgui.c.igValue_Int("Palette", selected.cell.fg.indexed); - } - - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.igColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Background Color"); - _ = cimgui.c.igTableSetColumnIndex(1); - switch (selected.cell.bg) { - .none => cimgui.c.igText("default"), - else => { - const rgb = switch (selected.cell.bg) { - .none => unreachable, - .indexed => |idx| self.surface.io.terminal.color_palette.colors[idx], - .rgb => |rgb| rgb, - }; - - if (selected.cell.bg == .indexed) { - cimgui.c.igValue_Int("Palette", selected.cell.bg.indexed); - } - - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.igColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "protected", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(selected.cell.attrs, style)) break :style; - - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText(style.ptr); - } - { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("true"); - } - } - } // table - - cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + selected.cell.renderTable( + self.surface.renderer_state.terminal, + selected.col, + selected.row, + ); } fn renderKeyboardWindow(self: *Inspector) void { @@ -1164,8 +1039,10 @@ fn renderTermioWindow(self: *Inspector) void { 0, ); defer cimgui.c.igEndTable(); - const palette = self.surface.io.terminal.color_palette.colors; - inspector.cursor.renderInTable(&ev.cursor, &palette); + inspector.cursor.renderInTable( + self.surface.renderer_state.terminal, + &ev.cursor, + ); { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index c1098a7c7e..c2491b2581 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -4,8 +4,8 @@ const terminal = @import("../terminal/main.zig"); /// Render cursor information with a table already open. pub fn renderInTable( + t: *const terminal.Terminal, cursor: *const terminal.Screen.Cursor, - palette: *const terminal.color.Palette, ) void { { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); @@ -27,7 +27,7 @@ pub fn renderInTable( } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(cursor.style).ptr); + cimgui.c.igText("%s", @tagName(cursor.cursor_style).ptr); } } @@ -48,19 +48,25 @@ pub fn renderInTable( _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Foreground Color"); _ = cimgui.c.igTableSetColumnIndex(1); - switch (cursor.pen.fg) { + switch (cursor.style.fg_color) { .none => cimgui.c.igText("default"), - else => { - const rgb = switch (cursor.pen.fg) { - .none => unreachable, - .indexed => |idx| palette[idx], - .rgb => |rgb| rgb, + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, - if (cursor.pen.fg == .indexed) { - cimgui.c.igValue_Int("Palette", cursor.pen.fg.indexed); - } - + .rgb => |rgb| { var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @@ -79,19 +85,25 @@ pub fn renderInTable( _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Background Color"); _ = cimgui.c.igTableSetColumnIndex(1); - switch (cursor.pen.bg) { + switch (cursor.style.bg_color) { .none => cimgui.c.igText("default"), - else => { - const rgb = switch (cursor.pen.bg) { - .none => unreachable, - .indexed => |idx| palette[idx], - .rgb => |rgb| rgb, + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, - if (cursor.pen.bg == .indexed) { - cimgui.c.igValue_Int("Palette", cursor.pen.bg.indexed); - } - + .rgb => |rgb| { var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @@ -108,11 +120,11 @@ pub fn renderInTable( // Boolean styles const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "protected", "strikethrough", + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", }; inline for (styles) |style| style: { - if (!@field(cursor.pen.attrs, style)) break :style; + if (!@field(cursor.style.flags, style)) break :style; cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); { From 172d62ca12083513d529d9db5ff0045b3f0c2c58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 10:28:55 -0700 Subject: [PATCH 312/428] inspector: get mouse points working --- src/Surface.zig | 8 +++- src/inspector/Inspector.zig | 89 +++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 573f131e06..4b17ddf408 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2569,8 +2569,12 @@ pub fn cursorPosCallback( if (self.inspector) |insp| { insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - // TODO(paged-terminal) - //insp.mouse.last_point = pos_vp.toScreen(&self.io.terminal.screen); + + const screen = &self.renderer_state.terminal.screen; + insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + } }); try self.queueRender(); } diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index fd7363f90f..1f36dd3b43 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -37,8 +37,7 @@ mouse: struct { last_ypos: f64 = 0, // Last hovered screen point - // TODO(paged-terminal) - // last_point: terminal.point.ScreenPoint = .{}, + last_point: ?terminal.Pin = null, } = .{}, /// A selected cell. @@ -696,25 +695,32 @@ fn renderSizeWindow(self: *Inspector) void { defer cimgui.c.igEndTable(); const mouse = &self.surface.mouse; - //const t = self.surface.renderer_state.terminal; - - // TODO(paged-terminal) - // { - // const hover_point = self.mouse.last_point.toViewport(&t.screen); - // cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - // { - // _ = cimgui.c.igTableSetColumnIndex(0); - // cimgui.c.igText("Hover Grid"); - // } - // { - // _ = cimgui.c.igTableSetColumnIndex(1); - // cimgui.c.igText( - // "row=%d, col=%d", - // hover_point.y, - // hover_point.x, - // ); - // } - // } + const t = self.surface.renderer_state.terminal; + + { + const hover_point: terminal.point.Coordinate = pt: { + const p = self.mouse.last_point orelse break :pt .{}; + const pt = t.screen.pages.pointFromPin( + .active, + p, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Hover Grid"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText( + "row=%d, col=%d", + hover_point.y, + hover_point.x, + ); + } + } { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); @@ -771,23 +777,30 @@ fn renderSizeWindow(self: *Inspector) void { } } - // TODO(paged-terminal) - // { - // const left_click_point = mouse.left_click_point.toViewport(&t.screen); - // cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - // { - // _ = cimgui.c.igTableSetColumnIndex(0); - // cimgui.c.igText("Click Grid"); - // } - // { - // _ = cimgui.c.igTableSetColumnIndex(1); - // cimgui.c.igText( - // "row=%d, col=%d", - // left_click_point.y, - // left_click_point.x, - // ); - // } - // } + { + const left_click_point: terminal.point.Coordinate = pt: { + const p = mouse.left_click_pin orelse break :pt .{}; + const pt = t.screen.pages.pointFromPin( + .active, + p.*, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Click Grid"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText( + "row=%d, col=%d", + left_click_point.y, + left_click_point.x, + ); + } + } { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); From 55b4e49cb6225fab5c5b72b43c074bc7e8395362 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 10:32:06 -0700 Subject: [PATCH 313/428] inspector: forgot the new file --- src/inspector/cell.zig | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/inspector/cell.zig diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig new file mode 100644 index 0000000000..36069eee0c --- /dev/null +++ b/src/inspector/cell.zig @@ -0,0 +1,193 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const cimgui = @import("cimgui"); +const terminal = @import("../terminal/main.zig"); + +/// A cell being inspected. This duplicates much of the data in +/// the terminal data structure because we want the inspector to +/// not have a reference to the terminal state or to grab any +/// locks. +pub const Cell = struct { + /// The main codepoint for this cell. + codepoint: u21, + + /// Codepoints for this cell to produce a single grapheme cluster. + /// This is only non-empty if the cell is part of a multi-codepoint + /// grapheme cluster. This does NOT include the primary codepoint. + cps: []const u21, + + /// The style of this cell. + style: terminal.Style, + + pub fn init( + alloc: Allocator, + pin: terminal.Pin, + ) !Cell { + const cell = pin.rowAndCell().cell; + const style = pin.style(cell); + const cps: []const u21 = if (cell.hasGrapheme()) cps: { + const src = pin.grapheme(cell).?; + assert(src.len > 0); + break :cps try alloc.dupe(u21, src); + } else &.{}; + errdefer if (cps.len > 0) alloc.free(cps); + + return .{ + .codepoint = cell.codepoint(), + .cps = cps, + .style = style, + }; + } + + pub fn deinit(self: *Cell, alloc: Allocator) void { + if (self.cps.len > 0) alloc.free(self.cps); + } + + pub fn renderTable( + self: *const Cell, + t: *const terminal.Terminal, + x: usize, + y: usize, + ) void { + // We have a selected cell, show information about it. + _ = cimgui.c.igBeginTable( + "table_cursor", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grid Position"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("row=%d col=%d", y, x); + } + } + + // NOTE: we don't currently write the character itself because + // we haven't hooked up imgui to our font system. That's hard! We + // can/should instead hook up our renderer to imgui and just render + // the single glyph in an image view so it looks _identical_ to the + // terminal. + codepoint: { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Codepoint"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + if (self.codepoint == 0) { + cimgui.c.igTextDisabled("(empty)"); + break :codepoint; + } + + cimgui.c.igText("U+%X", @as(u32, @intCast(self.codepoint))); + } + } + + // If we have a color then we show the color + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Foreground Color"); + _ = cimgui.c.igTableSetColumnIndex(1); + switch (self.style.fg_color) { + .none => cimgui.c.igText("default"), + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + + .rgb => |rgb| { + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_fg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Background Color"); + _ = cimgui.c.igTableSetColumnIndex(1); + switch (self.style.bg_color) { + .none => cimgui.c.igText("default"), + .palette => |idx| { + const rgb = t.color_palette.colors[idx]; + cimgui.c.igValue_Int("Palette", idx); + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + + .rgb => |rgb| { + var color: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.igColorEdit3( + "color_bg", + &color, + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } + + // Boolean styles + const styles = .{ + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", + }; + inline for (styles) |style| style: { + if (!@field(self.style.flags, style)) break :style; + + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText(style.ptr); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("true"); + } + } + + cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + } +}; From b677460258de8ca58170ec5a7ffe4203fb28128b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 10:57:27 -0700 Subject: [PATCH 314/428] inspector: add page system details --- src/inspector/Inspector.zig | 61 +++++++++++++++++++++++++++++++++++++ src/inspector/main.zig | 1 + 2 files changed, 62 insertions(+) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 1f36dd3b43..d44d6786bd 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -491,6 +491,67 @@ fn renderScreenWindow(self: *Inspector) void { } } // table } // kitty graphics + + if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + "Internal Terminal State", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + const pages = &screen.pages; + + { + _ = cimgui.c.igBeginTable( + "##terminal_state", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Usage"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", pages.page_size); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Limit"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", pages.max_size); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Viewport Location"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%s", @tagName(pages.viewport).ptr); + } + } + } // table + // + if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + "Active Page", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + inspector.page.render(&pages.pages.last.?.data); + } + } // terminal state } /// The modes window shows the currently active terminal modes and allows diff --git a/src/inspector/main.zig b/src/inspector/main.zig index c80384182c..ee871f2002 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); pub const cell = @import("cell.zig"); pub const cursor = @import("cursor.zig"); pub const key = @import("key.zig"); +pub const page = @import("page.zig"); pub const termio = @import("termio.zig"); pub const Cell = cell.Cell; From aadd0d3d48afa3c6d604556d6b94c7aa03a20a39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 11:04:03 -0700 Subject: [PATCH 315/428] config: increase default max scrollback to 10MB --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 568d43b2d9..af69260dd7 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -446,7 +446,7 @@ command: ?[]const u8 = null, /// This is a future planned feature. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit": u32 = 10_000, +@"scrollback-limit": u32 = 10_000_000, // 10MB /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions From dae4c3e52d2ad2c1b1a167e075dba83a72af7d20 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Mar 2024 11:04:13 -0700 Subject: [PATCH 316/428] inspector: forgot another new file --- src/inspector/page.zig | 169 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/inspector/page.zig diff --git a/src/inspector/page.zig b/src/inspector/page.zig new file mode 100644 index 0000000000..29e76be785 --- /dev/null +++ b/src/inspector/page.zig @@ -0,0 +1,169 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const cimgui = @import("cimgui"); +const terminal = @import("../terminal/main.zig"); + +pub fn render(page: *const terminal.Page) void { + cimgui.c.igPushID_Ptr(page); + defer cimgui.c.igPopID(); + + _ = cimgui.c.igBeginTable( + "##page_state", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Memory Size"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d bytes", page.memory.len); + cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Unique Styles"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", page.styles.count(page.memory)); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grapheme Entries"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", page.graphemeCount()); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Capacity"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.igBeginTable( + "##capacity", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + const cap = page.capacity; + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Columns"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.cols))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Rows"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.rows))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Unique Styles"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(cap.styles))); + } + } + + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Grapheme Bytes"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", cap.grapheme_bytes); + } + } + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Size"); + } + { + _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.igBeginTable( + "##size", + 2, + cimgui.c.ImGuiTableFlags_None, + .{ .x = 0, .y = 0 }, + 0, + ); + defer cimgui.c.igEndTable(); + + const size = page.size; + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Columns"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(size.cols))); + } + } + { + cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + { + _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.igText("Rows"); + } + + { + _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.igText("%d", @as(u32, @intCast(size.rows))); + } + } + } + } // size table +} From f4fa54984c23e58a4c38056d653234d2c9d46639 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 12:12:06 -0700 Subject: [PATCH 317/428] terminal: selectLine can disable whitespace/sem prompt splitting --- src/Surface.zig | 6 +- src/terminal/Screen.zig | 165 ++++++++++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 52 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4b17ddf408..73c6c12f29 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2361,7 +2361,7 @@ pub fn mouseButtonCallback( const sel_ = if (mods.ctrl) self.io.terminal.screen.selectOutput(pin.*) else - self.io.terminal.screen.selectLine(pin.*); + self.io.terminal.screen.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { try self.setSelection(sel); try self.queueRender(); @@ -2728,12 +2728,12 @@ fn dragLeftClickTriple( const click_pin = self.mouse.left_click_pin.?.*; // Get the word under our current point. If there isn't a word, do nothing. - const word = screen.selectLine(drag_pin) orelse return; + const word = screen.selectLine(.{ .pin = drag_pin }) orelse return; // Get our selection to grow it. If we don't have a selection, start it now. // We may not have a selection if we started our dbl-click in an area // that had no data, then we dragged our mouse into an area with data. - var sel = screen.selectLine(click_pin) orelse { + var sel = screen.selectLine(.{ .pin = click_pin }) orelse { try self.setSelection(word); return; }; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9e7f99e65f..23bd48e85e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1229,30 +1229,42 @@ pub fn selectionString( return string; } +pub const SelectLine = struct { + /// The pin of some part of the line to select. + pin: Pin, + + /// These are the codepoints to consider whitespace to trim + /// from the ends of the selection. + whitespace: ?[]const u21 = &.{ 0, ' ', '\t' }, + + /// If true, line selection will consider semantic prompt + /// state changing a boundary. State changing is ANY state + /// change. + semantic_prompt_boundary: bool = true, +}; + /// 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 { +pub fn selectLine(self: *Screen, opts: SelectLine) ?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(); + const semantic_prompt_state: ?bool = state: { + if (!opts.semantic_prompt_boundary) break :state null; + const rac = opts.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; + var it = opts.pin.rowIterator(.left_up, null); + var it_prev: Pin = opts.pin; while (it.next()) |p| { const row = p.rowAndCell().row; @@ -1262,12 +1274,14 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { 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; + if (semantic_prompt_state) |v| { + // See semantic_prompt_state comment for why + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != v) { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; + } } it_prev = p; @@ -1280,16 +1294,18 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // 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); + var it = opts.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 (semantic_prompt_state) |v| { + // See semantic_prompt_state comment for why + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != v) { + var prev = p.up(1).?; + prev.x = p.page.data.size.cols - 1; + break :end_pin prev; + } } if (!row.wrap) { @@ -1304,6 +1320,7 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // Go forward from the start to find the first non-whitespace character. const start: Pin = start: { + const whitespace = opts.whitespace orelse break :start start_pin; var it = start_pin.cellIterator(.right_down, end_pin); while (it.next()) |p| { const cell = p.rowAndCell().cell; @@ -1311,9 +1328,9 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // Non-empty means we found it. const this_whitespace = std.mem.indexOfAny( - u32, + u21, whitespace, - &[_]u32{cell.content.codepoint}, + &[_]u21{cell.content.codepoint}, ) != null; if (this_whitespace) continue; @@ -1325,6 +1342,7 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // Go backward from the end to find the first non-whitespace character. const end: Pin = end: { + const whitespace = opts.whitespace orelse break :end end_pin; var it = end_pin.cellIterator(.left_up, start_pin); while (it.next()) |p| { const cell = p.rowAndCell().cell; @@ -1332,9 +1350,9 @@ pub fn selectLine(self: *Screen, pin: Pin) ?Selection { // Non-empty means we found it. const this_whitespace = std.mem.indexOfAny( - u32, + u21, whitespace, - &[_]u32{cell.content.codepoint}, + &[_]u21{cell.content.codepoint}, ) != null; if (this_whitespace) continue; @@ -4883,10 +4901,10 @@ test "Screen: selectLine" { // Going forward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -4900,10 +4918,10 @@ test "Screen: selectLine" { // Going backward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 7, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -4917,10 +4935,10 @@ test "Screen: selectLine" { // Going forward and backward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -4934,10 +4952,10 @@ test "Screen: selectLine" { // Outside active area { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 9, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -4960,10 +4978,10 @@ test "Screen: selectLine across soft-wrap" { // Going forward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -4986,10 +5004,10 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { // Going forward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -5003,10 +5021,10 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { // Going backward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -5020,10 +5038,10 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { // Going forward and backward { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -5036,6 +5054,55 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { } } +test "Screen: selectLine disabled whitespace trimming" { + 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(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?, + .whitespace = null, + }).?; + 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 = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Non-wrapped + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 3, + } }).?, + .whitespace = null, + }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -5046,10 +5113,10 @@ test "Screen: selectLine with scrollback" { // Selecting first line { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -5063,10 +5130,10 @@ test "Screen: selectLine with scrollback" { // Selecting last line { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 2, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -5102,10 +5169,10 @@ test "Screen: selectLine semantic prompt boundary" { // Selecting output stops at the prompt even if soft-wrapped { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -5117,10 +5184,10 @@ test "Screen: selectLine semantic prompt boundary" { } }, s.pages.pointFromPin(.active, sel.end()).?); } { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?).?; + } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, From e18a77739cbff4223643309606ff8608b4fc60cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 19:46:14 -0700 Subject: [PATCH 318/428] terminal: screen lineIterator --- src/terminal-old/Screen.zig | 3 ++ src/terminal/PageList.zig | 1 + src/terminal/Screen.zig | 82 ++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/terminal-old/Screen.zig b/src/terminal-old/Screen.zig index 23da1728c2..9178571280 100644 --- a/src/terminal-old/Screen.zig +++ b/src/terminal-old/Screen.zig @@ -3717,6 +3717,7 @@ test "Screen: write long emoji" { try testing.expectEqual(@as(usize, 5), s.cursor.x); } +// X test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator; @@ -3745,6 +3746,7 @@ test "Screen: lineIterator" { try testing.expect(iter.next() == null); } +// X test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator; @@ -3773,6 +3775,7 @@ test "Screen: lineIterator soft wrap" { try testing.expect(iter.next() == null); } +// X - selectLine in new screen test "Screen: getLine soft wrap" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ce15faf057..c2019fbfe2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -10,6 +10,7 @@ const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); const size = @import("size.zig"); +const Selection = @import("Selection.zig"); const OffsetBuf = size.OffsetBuf; const Capacity = pagepkg.Capacity; const Page = pagepkg.Page; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 23bd48e85e..2c66332dca 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1247,7 +1247,7 @@ pub const SelectLine = struct { /// 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, opts: SelectLine) ?Selection { +pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { _ = self; // Get the current point semantic prompt state since that determines @@ -1713,6 +1713,35 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { return Selection.init(start, end, false); } +pub const LineIterator = struct { + screen: *const Screen, + current: ?Pin = null, + + pub fn next(self: *LineIterator) ?Selection { + const current = self.current orelse return null; + const result = self.screen.selectLine(.{ + .pin = current, + .whitespace = null, + .semantic_prompt_boundary = false, + }) orelse { + self.current = null; + return null; + }; + + self.current = result.end().down(1); + return result; + } +}; + +/// Returns an iterator to move through the soft-wrapped lines starting +/// from pin. +pub fn lineIterator(self: *const Screen, start: Pin) LineIterator { + return LineIterator{ + .screen = self, + .current = start, + }; +} + /// 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. @@ -6355,3 +6384,54 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } + +test "Screen: lineIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH"; + try s.testWriteString(str); + + // Test the line iterator + var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, sel, false); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD", actual); + } + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, sel, false); + defer alloc.free(actual); + try testing.expectEqualStrings("2EFGH", actual); + } +} + +test "Screen: lineIterator soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3ABCD"; + try s.testWriteString(str); + + // Test the line iterator + var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, sel, false); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD2EFGH", actual); + } + { + const sel = iter.next().?; + const actual = try s.selectionString(alloc, sel, false); + defer alloc.free(actual); + try testing.expectEqualStrings("3ABCD", actual); + } + // try testing.expect(iter.next() == null); +} From 2de86ce500a0faedc9fbf3c12e243d1e55531a60 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 19:59:15 -0700 Subject: [PATCH 319/428] core: converting more to new screen state --- src/Surface.zig | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 73c6c12f29..348d9a9981 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2459,24 +2459,33 @@ fn linkAtPos( DerivedConfig.Link, terminal.Selection, } { - // TODO(paged-terminal) - if (true) return null; - // If we have no configured links we can save a lot of work if (self.config.links.len == 0) return null; // Convert our cursor position to a screen point. - const mouse_pt = mouse_pt: { - const viewport_point = self.posToViewport(pos.x, pos.y); - break :mouse_pt viewport_point.toScreen(&self.io.terminal.screen); + const screen = &self.renderer_state.terminal.screen; + const mouse_pin: terminal.Pin = mouse_pin: { + const point = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ .viewport = point }) orelse { + log.warn("failed to get pin for clicked point", .{}); + return null; + }; + break :mouse_pin pin; }; // Get our comparison mods const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // Get the line we're hovering over. - const line = self.io.terminal.screen.getLine(mouse_pt) orelse - return null; + const line = screen.selectLine(.{ + .pin = mouse_pin, + .whitespace = null, + .semantic_prompt_boundary = false, + }) orelse return null; + + // TODO(paged-terminal) + if (true) return null; + const strmap = try line.stringMap(self.alloc); defer strmap.deinit(self.alloc); @@ -2492,7 +2501,7 @@ fn linkAtPos( var match = (try it.next()) orelse break; defer match.deinit(); const sel = match.selection(); - if (!sel.contains(mouse_pt)) continue; + if (!sel.contains(screen, mouse_pin)) continue; return .{ link, sel }; } } From bca51ee7719b07e5726c99b662a4ce8e2daefd76 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 20:07:49 -0700 Subject: [PATCH 320/428] terminal: selectionString takes a struct for opts --- src/Surface.zig | 32 +++++------ src/terminal/Screen.zig | 124 ++++++++++++++++++++++++++++++---------- src/terminal/main.zig | 1 + 3 files changed, 112 insertions(+), 45 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 348d9a9981..7ebbb364a2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -946,7 +946,10 @@ pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const sel = self.io.terminal.screen.selection orelse return null; - return try self.io.terminal.screen.selectionString(alloc, sel, false); + return try self.io.terminal.screen.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); } /// Returns the pwd of the terminal, if any. This is always copied because @@ -1075,11 +1078,10 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { } } - const buf = self.io.terminal.screen.selectionString( - self.alloc, - sel, - self.config.clipboard_trim_trailing_spaces, - ) catch |err| { + const buf = self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = self.config.clipboard_trim_trailing_spaces, + }) catch |err| { log.err("error reading selection string err={}", .{err}); return; }; @@ -2536,11 +2538,10 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { const link, const sel = try self.linkAtPos(pos) orelse return false; switch (link.action) { .open => { - const str = try self.io.terminal.screen.selectionString( - self.alloc, - sel, - false, - ); + const str = try self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = false, + }); defer self.alloc.free(str); try internal_os.open(self.alloc, str); }, @@ -3104,11 +3105,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // We can read from the renderer state without holding // the lock because only we will write to this field. if (self.io.terminal.screen.selection) |sel| { - const buf = self.io.terminal.screen.selectionString( - self.alloc, - sel, - self.config.clipboard_trim_trailing_spaces, - ) catch |err| { + const buf = self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = sel, + .trim = self.config.clipboard_trim_trailing_spaces, + }) catch |err| { log.err("error reading selection string err={}", .{err}); return true; }; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2c66332dca..d351de6ecf 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1106,22 +1106,25 @@ pub fn clearSelection(self: *Screen) void { self.selection = null; } +pub const SelectionString = struct { + /// The selection to convert to a string. + sel: Selection, + + /// If true, trim whitespace around the selection. + trim: bool, +}; + /// 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 { +pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ![: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_ordered = opts.sel.ordered(self, .forward); const sel_start = start: { var start = sel_ordered.start(); const cell = start.rowAndCell().cell; @@ -1199,7 +1202,7 @@ pub fn selectionString( // 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) { + if (opts.trim) { var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); // Reset our items. We retain our capacity. Because we're only @@ -6020,7 +6023,10 @@ test "Screen: selectionString basic" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "2EFGH\n3IJ"; try testing.expectEqualStrings(expected, contents); @@ -6042,7 +6048,10 @@ test "Screen: selectionString start outside of written area" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = ""; try testing.expectEqualStrings(expected, contents); @@ -6064,7 +6073,10 @@ test "Screen: selectionString end outside of written area" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); @@ -6087,7 +6099,10 @@ test "Screen: selectionString trim space" { ); { - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "1AB\n2EF"; try testing.expectEqualStrings(expected, contents); @@ -6095,7 +6110,10 @@ test "Screen: selectionString trim space" { // No trim { - const contents = try s.selectionString(alloc, sel, false); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(contents); const expected = "1AB \n2EF"; try testing.expectEqualStrings(expected, contents); @@ -6118,7 +6136,10 @@ test "Screen: selectionString trim empty line" { ); { - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "1AB\n\n2EF"; try testing.expectEqualStrings(expected, contents); @@ -6126,7 +6147,10 @@ test "Screen: selectionString trim empty line" { // No trim { - const contents = try s.selectionString(alloc, sel, false); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(contents); const expected = "1AB \n \n2EF"; try testing.expectEqualStrings(expected, contents); @@ -6148,7 +6172,10 @@ test "Screen: selectionString soft wrap" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "2EFGH3IJ"; try testing.expectEqualStrings(expected, contents); @@ -6170,7 +6197,10 @@ test "Screen: selectionString wide char" { s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); @@ -6182,7 +6212,10 @@ test "Screen: selectionString wide char" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); @@ -6194,7 +6227,10 @@ test "Screen: selectionString wide char" { s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "⚡"; try testing.expectEqualStrings(expected, contents); @@ -6216,7 +6252,10 @@ test "Screen: selectionString wide char with header" { s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); @@ -6247,7 +6286,10 @@ test "Screen: selectionString empty with soft wrap" { s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "👨"; try testing.expectEqualStrings(expected, contents); @@ -6280,7 +6322,10 @@ test "Screen: selectionString with zero width joiner" { s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, false, ); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); const expected = "👨‍"; try testing.expectEqualStrings(expected, contents); @@ -6312,7 +6357,10 @@ test "Screen: selectionString, rectangle, basic" { ; try s.testWriteString(str); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } @@ -6344,7 +6392,10 @@ test "Screen: selectionString, rectangle, w/EOL" { ; try s.testWriteString(str); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } @@ -6380,7 +6431,10 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { ; try s.testWriteString(str); - const contents = try s.selectionString(alloc, sel, true); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = true, + }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } @@ -6398,13 +6452,19 @@ test "Screen: lineIterator" { var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); { const sel = iter.next().?; - const actual = try s.selectionString(alloc, sel, false); + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(actual); try testing.expectEqualStrings("1ABCD", actual); } { const sel = iter.next().?; - const actual = try s.selectionString(alloc, sel, false); + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(actual); try testing.expectEqualStrings("2EFGH", actual); } @@ -6423,13 +6483,19 @@ test "Screen: lineIterator soft wrap" { var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); { const sel = iter.next().?; - const actual = try s.selectionString(alloc, sel, false); + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(actual); try testing.expectEqualStrings("1ABCD2EFGH", actual); } { const sel = iter.next().?; - const actual = try s.selectionString(alloc, sel, false); + const actual = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); defer alloc.free(actual); try testing.expectEqualStrings("3ABCD", actual); } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index ea2e65240b..05ebf069f4 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -35,6 +35,7 @@ pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); +//pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; From d664840b7f7b356f87ba9ae554675e531427acc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 20:39:21 -0700 Subject: [PATCH 321/428] terminal: add StringMap back --- src/terminal-old/main.zig | 1 - src/terminal/Screen.zig | 78 +++++++++++++++++++-- src/terminal/StringMap.zig | 140 +++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 2 +- 4 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 src/terminal/StringMap.zig diff --git a/src/terminal-old/main.zig b/src/terminal-old/main.zig index 5f29ce70c7..0e6856a10b 100644 --- a/src/terminal-old/main.zig +++ b/src/terminal-old/main.zig @@ -42,7 +42,6 @@ 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. diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d351de6ecf..d22d185b2d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -10,6 +10,7 @@ const sgr = @import("sgr.zig"); const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); +const StringMap = @import("StringMap.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -1111,7 +1112,12 @@ pub const SelectionString = struct { sel: Selection, /// If true, trim whitespace around the selection. - trim: bool, + trim: bool = true, + + /// If non-null, a stringmap will be written here. This will use + /// the same allocator as the call to selectionString. The string will + /// be duplicated here and in the return value so both must be freed. + map: ?*StringMap = null, }; /// Returns the raw text associated with a selection. This will unwrap @@ -1124,6 +1130,11 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ! var strbuilder = std.ArrayList(u8).init(alloc); defer strbuilder.deinit(); + // If we're building a stringmap, create our builder for the pins. + const MapBuilder = std.ArrayList(Pin); + var mapbuilder: ?MapBuilder = if (opts.map != null) MapBuilder.init(alloc) else null; + defer if (mapbuilder) |*b| b.deinit(); + const sel_ordered = opts.sel.ordered(self, .forward); const sel_start = start: { var start = sel_ordered.start(); @@ -1153,7 +1164,7 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ! var row_count: usize = 0; while (page_it.next()) |chunk| { const rows = chunk.rows(); - for (rows) |row| { + for (rows, chunk.start..) |row, y| { const cells_ptr = row.cells.ptr(chunk.page.data.memory); const start_x = if (row_count == 0 or sel_ordered.rectangle) @@ -1166,7 +1177,7 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ! self.pages.cols; const cells = cells_ptr[start_x..end_x]; - for (cells) |*cell| { + for (cells, start_x..) |*cell, x| { // Skip wide spacers switch (cell.wide) { .narrow, .wide => {}, @@ -1179,12 +1190,26 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ! const char = if (raw > 0) raw else ' '; const encode_len = try std.unicode.utf8Encode(char, &buf); try strbuilder.appendSlice(buf[0..encode_len]); + if (mapbuilder) |*b| { + for (0..encode_len) |_| try b.append(.{ + .page = chunk.page, + .y = y, + .x = x, + }); + } } 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 (mapbuilder) |*b| { + for (0..encode_len) |_| try b.append(.{ + .page = chunk.page, + .y = y, + .x = x, + }); + } } } } @@ -1193,12 +1218,32 @@ pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ! (!row.wrap or sel_ordered.rectangle)) { try strbuilder.append('\n'); + if (mapbuilder) |*b| try b.append(.{ + .page = chunk.page, + .y = y, + .x = chunk.page.data.size.cols - 1, + }); } row_count += 1; } } + if (comptime std.debug.runtime_safety) { + if (mapbuilder) |b| assert(strbuilder.items.len == b.items.len); + } + + // If we have a mapbuilder, we need to setup our string map. + if (mapbuilder) |*b| { + var strclone = try strbuilder.clone(); + defer strclone.deinit(); + const str = try strclone.toOwnedSliceSentinel(0); + errdefer alloc.free(str); + const map = try b.toOwnedSlice(); + errdefer alloc.free(map); + opts.map.?.* = .{ .string = str, .map = map }; + } + // 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. @@ -1267,7 +1312,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // The real start of the row is the first row in the soft-wrap. const start_pin: Pin = start_pin: { var it = opts.pin.rowIterator(.left_up, null); - var it_prev: Pin = opts.pin; + var it_prev: Pin = it.next().?; // skip self while (it.next()) |p| { const row = p.rowAndCell().row; @@ -5026,6 +5071,31 @@ test "Screen: selectLine across soft-wrap" { } } +test "Screen: selectLine across full soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD2EFGH\n3IJKL"); + + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .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 = 4, + .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; diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig new file mode 100644 index 0000000000..9892c13df3 --- /dev/null +++ b/src/terminal/StringMap.zig @@ -0,0 +1,140 @@ +/// A string along with the mapping of each individual byte in the string +/// to the point in the screen. +const StringMap = @This(); + +const std = @import("std"); +const oni = @import("oniguruma"); +const point = @import("point.zig"); +const Selection = @import("Selection.zig"); +const Screen = @import("Screen.zig"); +const Pin = @import("PageList.zig").Pin; +const Allocator = std.mem.Allocator; + +string: [:0]const u8, +map: []Pin, + +pub fn deinit(self: StringMap, alloc: Allocator) void { + alloc.free(self.string); + alloc.free(self.map); +} + +/// Returns an iterator that yields the next match of the given regex. +pub fn searchIterator( + self: StringMap, + regex: oni.Regex, +) SearchIterator { + return .{ .map = self, .regex = regex }; +} + +/// Iterates over the regular expression matches of the string. +pub const SearchIterator = struct { + map: StringMap, + regex: oni.Regex, + offset: usize = 0, + + /// Returns the next regular expression match or null if there are + /// no more matches. + pub fn next(self: *SearchIterator) !?Match { + if (self.offset >= self.map.string.len) return null; + + var region = self.regex.search( + self.map.string[self.offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => { + self.offset = self.map.string.len; + return null; + }, + + else => return err, + }; + errdefer region.deinit(); + + // Increment our offset by the number of bytes in the match. + // We defer this so that we can return the match before + // modifying the offset. + const end_idx: usize = @intCast(region.ends()[0]); + defer self.offset += end_idx; + + return .{ + .map = self.map, + .offset = self.offset, + .region = region, + }; + } +}; + +/// A single regular expression match. +pub const Match = struct { + map: StringMap, + offset: usize, + region: oni.Region, + + pub fn deinit(self: *Match) void { + self.region.deinit(); + } + + /// Returns the selection containing the full match. + pub fn selection(self: Match) Selection { + const start_idx: usize = @intCast(self.region.starts()[0]); + const end_idx: usize = @intCast(self.region.ends()[0] - 1); + const start_pt = self.map.map[self.offset + start_idx]; + const end_pt = self.map.map[self.offset + end_idx]; + return Selection.init(start_pt, end_pt, false); + } +}; + +test "StringMap searchIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + // Initialize our regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + "[A-B]{2}", + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen + var s = try Screen.init(alloc, 5, 5, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + const line = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).?, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); + defer map.deinit(alloc); + + // Get our iterator + var it = map.searchIterator(re); + { + var match = (try it.next()).?; + defer match.deinit(); + + const sel = match.selection(); + 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 = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + try testing.expect(try it.next() == null); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 05ebf069f4..cf05771ec4 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -35,7 +35,7 @@ pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Selection = @import("Selection.zig"); -//pub const StringMap = @import("StringMap.zig"); +pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; From 5664c3e3c99af8c27335c92365c87411189896dc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 20:41:10 -0700 Subject: [PATCH 322/428] core: enable link hovering --- src/Surface.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 7ebbb364a2..a5ecb3eba5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2485,10 +2485,12 @@ fn linkAtPos( .semantic_prompt_boundary = false, }) orelse return null; - // TODO(paged-terminal) - if (true) return null; - - const strmap = try line.stringMap(self.alloc); + var strmap: terminal.StringMap = undefined; + self.alloc.free(try screen.selectionString(self.alloc, .{ + .sel = line, + .trim = false, + .map = &strmap, + })); defer strmap.deinit(self.alloc); // Go through each link and see if we clicked it From 7419794a7b512776f10d5ffd2b5f79226a7f6ef2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 20:58:59 -0700 Subject: [PATCH 323/428] renderer: convert link to new state --- src/renderer/link.zig | 389 +++++++++++++++++++++++++----------------- 1 file changed, 237 insertions(+), 152 deletions(-) diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 4c16ed3a2b..e6c7f6ba04 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -64,11 +64,13 @@ pub const Set = struct { self: *const Set, alloc: Allocator, screen: *Screen, - mouse_vp_pt: point.Viewport, + mouse_vp_pt: point.Coordinate, mouse_mods: inputpkg.Mods, ) !MatchSet { // Convert the viewport point to a screen point. - const mouse_pt = mouse_vp_pt.toScreen(screen); + const mouse_pin = screen.pages.pin(.{ + .viewport = mouse_vp_pt, + }) orelse return .{}; // This contains our list of matches. The matches are stored // as selections which contain the start and end points of @@ -78,14 +80,25 @@ pub const Set = struct { defer matches.deinit(); // Iterate over all the visible lines. - var lineIter = screen.lineIterator(.viewport); - while (lineIter.next()) |line| { - const strmap = line.stringMap(alloc) catch |err| { - log.warn( - "failed to build string map for link checking err={}", - .{err}, - ); - continue; + var lineIter = screen.lineIterator(screen.pages.pin(.{ + .viewport = .{}, + }) orelse return .{}); + while (lineIter.next()) |line_sel| { + const strmap: terminal.StringMap = strmap: { + var strmap: terminal.StringMap = undefined; + const str = screen.selectionString(alloc, .{ + .sel = line_sel, + .trim = false, + .map = &strmap, + }) catch |err| { + log.warn( + "failed to build string map for link checking err={}", + .{err}, + ); + continue; + }; + alloc.free(str); + break :strmap strmap; }; defer strmap.deinit(alloc); @@ -98,7 +111,7 @@ pub const Set = struct { .always => {}, .always_mods => |v| if (!mouse_mods.equal(v)) continue, inline .hover, .hover_mods => |v, tag| { - if (!line.selection().contains(mouse_pt)) continue; + if (!line_sel.contains(screen, mouse_pin)) continue; if (comptime tag == .hover_mods) { if (!mouse_mods.equal(v)) continue; } @@ -121,7 +134,7 @@ pub const Set = struct { .always, .always_mods => {}, .hover, .hover_mods, - => if (!sel.contains(mouse_pt)) continue, + => if (!sel.contains(screen, mouse_pin)) continue, } try matches.append(sel); @@ -153,156 +166,228 @@ pub const MatchSet = struct { /// results. pub fn orderedContains( self: *MatchSet, - pt: point.ScreenPoint, + screen: *const Screen, + pin: terminal.Pin, ) bool { // If we're beyond the end of our possible matches, we're done. if (self.i >= self.matches.len) return false; // If our selection ends before the point, then no point will ever // again match this selection so we move on to the next one. - while (self.matches[self.i].end.before(pt)) { + while (self.matches[self.i].end().before(pin)) { self.i += 1; if (self.i >= self.matches.len) return false; } - return self.matches[self.i].contains(pt); + return self.matches[self.i].contains(screen, pin); } }; -// 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 })); -// } +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(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .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(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .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(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .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(&s, s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 0, + } }).?)); + try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 3, + .y = 0, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?)); + try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ + .x = 1, + .y = 2, + } }).?)); +} From fd9280429e3632506e5ef040072d249776b165b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Mar 2024 21:07:16 -0700 Subject: [PATCH 324/428] renderer: re-enable URL underlining --- src/renderer/Metal.zig | 35 ++++++++++++++--------------------- src/renderer/OpenGL.zig | 37 ++++++++++++++----------------------- 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 0d546b7a75..e89454f9d9 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1553,17 +1553,14 @@ fn rebuildCells( var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); const arena_alloc = arena.allocator(); - _ = arena_alloc; - _ = mouse; // Create our match set for the links. - // TODO(paged-terminal) - // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - // arena_alloc, - // screen, - // mouse_pt, - // mouse.mods, - // ) else .{}; + var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + arena_alloc, + screen, + mouse_pt, + mouse.mods, + ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -1672,21 +1669,15 @@ fn rebuildCells( var copy = row; copy.x = shaper_cell.x; break :cell copy; - - // TODO(paged-terminal) - // // If our links contain this cell then we want to - // // underline it. - // if (link_match_set.orderedContains(.{ - // .x = shaper_cell.x, - // .y = y, - // })) { - // cell.attrs.underline = .single; - // } }; if (self.updateCell( screen, cell, + if (link_match_set.orderedContains(screen, cell)) + .single + else + null, color_palette, shaper_cell, run, @@ -1754,6 +1745,7 @@ fn updateCell( self: *Metal, screen: *const terminal.Screen, cell_pin: terminal.Pin, + cell_underline: ?terminal.Attribute.Underline, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1781,6 +1773,7 @@ fn updateCell( const rac = cell_pin.rowAndCell(); const cell = rac.cell; const style = cell_pin.style(cell); + const underline = cell_underline orelse style.flags.underline; // The colors for the cell. const colors: BgFg = colors: { @@ -1910,8 +1903,8 @@ fn updateCell( }); } - if (style.flags.underline != .none) { - const sprite: font.Sprite = switch (style.flags.underline) { + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { .none => unreachable, .single => .underline, .double => .underline_double, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index fab2ae37ec..47d660b346 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -974,20 +974,17 @@ pub fn rebuildCells( var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); const arena_alloc = arena.allocator(); - _ = arena_alloc; - _ = mouse; // We've written no data to the GPU, refresh it all self.gl_cells_written = 0; // Create our match set for the links. - // TODO(paged-terminal) - // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - // arena_alloc, - // screen, - // mouse_pt, - // mouse.mods, - // ) else .{}; + var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + arena_alloc, + screen, + mouse_pt, + mouse.mods, + ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -1085,23 +1082,15 @@ pub fn rebuildCells( var copy = row; copy.x = shaper_cell.x; break :cell copy; - - // TODO(paged-terminal) - // // If our links contain this cell then we want to - // // underline it. - // if (link_match_set.orderedContains(.{ - // .x = shaper_cell.x, - // .y = y, - // })) { - // cell.attrs.underline = .single; - // } - // - // break :cell cell; }; if (self.updateCell( screen, cell, + if (link_match_set.orderedContains(screen, cell)) + .single + else + null, color_palette, shaper_cell, run, @@ -1330,6 +1319,7 @@ fn updateCell( self: *OpenGL, screen: *terminal.Screen, cell_pin: terminal.Pin, + cell_underline: ?terminal.Attribute.Underline, palette: *const terminal.color.Palette, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, @@ -1357,6 +1347,7 @@ fn updateCell( const rac = cell_pin.rowAndCell(); const cell = rac.cell; const style = cell_pin.style(cell); + const underline = cell_underline orelse style.flags.underline; // The colors for the cell. const colors: BgFg = colors: { @@ -1507,8 +1498,8 @@ fn updateCell( }); } - if (style.flags.underline != .none) { - const sprite: font.Sprite = switch (style.flags.underline) { + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { .none => unreachable, .single => .underline, .double => .underline_double, From 9630c39ea4c4382fc9e7926478b64a311ab4868a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 14 Mar 2024 19:16:17 -0600 Subject: [PATCH 325/428] terminal/page: improved capacity adjust logic --- src/terminal/page.zig | 58 ++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7424d18631..adcb19068f 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -15,6 +15,7 @@ const BitmapAllocator = @import("bitmap_allocator.zig").BitmapAllocator; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const alignForward = std.mem.alignForward; +const alignBackward = std.mem.alignBackward; /// The allocator to use for multi-codepoint grapheme data. We use /// a chunk size of 4 codepoints. It'd be best to set this empirically @@ -587,16 +588,39 @@ pub const Capacity = struct { pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity { var adjusted = self; if (req.cols) |cols| { - // The calculations below only work if cells/rows match size. - assert(@sizeOf(Cell) == @sizeOf(Row)); + // The math below only works if there is no alignment gap between + // the end of the rows array and the start of the cells array. + // + // To guarantee this, we assert that Row's size is a multiple of + // Cell's alignment, so that any length array of Rows will end on + // a valid alignment for the start of the Cell array. + assert(@sizeOf(Row) % @alignOf(Cell) == 0); - // total_size = (Nrows * sizeOf(Row)) + (Nrows * Ncells * sizeOf(Cell)) - // with some algebra: - // Nrows = total_size / (sizeOf(Row) + (Ncells * sizeOf(Cell))) const layout = Page.layout(self); - const total_size = layout.rows_size + layout.cells_size; - const denom = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); - const new_rows = @divFloor(total_size, denom); + + // In order to determine the amount of space in the page available + // for rows & cells (which will allow us to calculate the number of + // rows we can fit at a certain column width) we need to layout the + // "meta" members of the page (i.e. everything else) from the end. + const grapheme_map_start = alignBackward( + usize, + layout.total_size - layout.grapheme_map_layout.total_size, + GraphemeMap.base_align + ); + const grapheme_alloc_start = alignBackward( + usize, + grapheme_map_start - layout.grapheme_alloc_layout.total_size, + GraphemeAlloc.base_align + ); + const styles_start = alignBackward( + usize, + grapheme_alloc_start - layout.styles_layout.total_size, + style.Set.base_align + ); + + const available_size = styles_start; + const size_per_row = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); + const new_rows = @divFloor(available_size, size_per_row); // If our rows go to zero then we can't fit any row metadata // for the desired number of columns. @@ -606,24 +630,6 @@ pub const Capacity = struct { adjusted.rows = @intCast(new_rows); } - // Adjust our rows so that we have an exact total size count. - // I think we could do this with basic math but my grade school - // algebra skills are failing me and I'm embarassed so please someone - // fix this. This is tested so you can fiddle around. - const old_size = Page.layout(self).total_size; - var new_size = Page.layout(adjusted).total_size; - while (old_size != new_size) { - // Our math above is usually PRETTY CLOSE (like within 1 row) - // so we can just adjust by 1 row at a time. - if (new_size > old_size) { - adjusted.rows -= 1; - } else { - adjusted.rows += 1; - } - - new_size = Page.layout(adjusted).total_size; - } - return adjusted; } }; From 869b6b18e8fc28eed0b5cf5330638d9b5d1cf8c5 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 14 Mar 2024 19:17:31 -0600 Subject: [PATCH 326/428] terminal/page: improve capacity adjust cols tests --- src/terminal/page.zig | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index adcb19068f..65ade4e093 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -874,6 +874,12 @@ test "Page capacity adjust cols down" { const adjusted = try original.adjust(.{ .cols = original.cols / 2 }); const adjusted_size = Page.layout(adjusted).total_size; try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); } test "Page capacity adjust cols down to 1" { @@ -882,6 +888,12 @@ test "Page capacity adjust cols down to 1" { const adjusted = try original.adjust(.{ .cols = 1 }); const adjusted_size = Page.layout(adjusted).total_size; try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); } test "Page capacity adjust cols up" { @@ -890,6 +902,29 @@ test "Page capacity adjust cols up" { const adjusted = try original.adjust(.{ .cols = original.cols * 2 }); const adjusted_size = Page.layout(adjusted).total_size; try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols sweep" { + var cap = std_capacity; + const original_cols = cap.cols; + const original_size = Page.layout(cap).total_size; + for (1..original_cols*2) |c| { + cap = try cap.adjust(.{ .cols = @as(u16, @intCast(c)) }); + const adjusted_size = Page.layout(cap).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = cap; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); + } } test "Page capacity adjust cols too high" { From 7ad3195794e7c7d80a49309e74a23af0ee638934 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 16 Mar 2024 13:30:03 -0700 Subject: [PATCH 327/428] ci: create PR releases --- .github/workflows/release-pr.yml | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/release-pr.yml diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000000..c4320cf3ef --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,151 @@ +on: + pull_request: + types: [opened, reopened, synchronized] + + workflow_dispatch: {} + +name: Release PR + +jobs: + build-macos: + runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Important so that build number generation works + fetch-depth: 0 + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v26 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v14 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + # Setup Sparkle + - name: Setup Sparkle + env: + SPARKLE_VERSION: 2.5.1 + run: | + mkdir -p .action/sparkle + cd .action/sparkle + curl -L https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-for-Swift-Package-Manager.zip > sparkle.zip + unzip sparkle.zip + echo "$(pwd)/bin" >> $GITHUB_PATH + + # Load Build Number + - name: Build Number + run: | + echo "GHOSTTY_BUILD=$(git rev-list --count head)" >> $GITHUB_ENV + echo "GHOSTTY_COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + # GhosttyKit is the framework that is built from Zig for our native + # Mac app to access. Build this in release mode. + - name: Build GhosttyKit + run: nix develop -c zig build -Dstatic=true -Doptimize=ReleaseFast + + # The native app is built with native XCode tooling. This also does + # codesigning. IMPORTANT: this must NOT run in a Nix environment. + # Nix breaks xcodebuild so this has to be run outside. + - name: Build Ghostty.app + run: cd macos && xcodebuild -target Ghostty -configuration Release + + # We inject the "build number" as simply the number of commits since HEAD. + # This will be a monotonically always increasing build number that we use. + - name: Update Info.plist + env: + SPARKLE_KEY_PUB: ${{ secrets.PROD_MACOS_SPARKLE_KEY_PUB }} + run: | + # Version Info + /usr/libexec/PlistBuddy -c "Set :GhosttyCommit $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $GHOSTTY_BUILD" "macos/build/Release/Ghostty.app/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist" + + # Updater + /usr/libexec/PlistBuddy -c "Set :SUPublicEDKey $SPARKLE_KEY_PUB" "macos/build/Release/Ghostty.app/Contents/Info.plist" + + - name: Codesign app bundle + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + # Turn our base64-encoded certificate back to a regular .p12 file + echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 + + # We need to create a new keychain, otherwise using the certificate will prompt + # with a UI dialog asking for the certificate password, which we can't + # use in a headless CI environment + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain + + # Codesign Sparkle. Some notes here: + # - The XPC services aren't used since we don't sandbox Ghostty, + # but since they're part of the build, they still need to be + # codesigned. + # - The binaries in the "Versions" folders need to NOT be symlinks. + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + + # Codesign the app bundle + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app + + - name: "Notarize app bundle" + env: + PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + run: | + # Store the notarization credentials so that we can prevent a UI password dialog + # from blocking the CI + echo "Create keychain profile" + xcrun notarytool store-credentials "notarytool-profile" --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" --password "$PROD_MACOS_NOTARIZATION_PWD" + + # We can't notarize an app bundle directly, but we need to compress it as an archive. + # Therefore, we create a zip file containing our app bundle, so that we can send it to the + # notarization service + echo "Creating temp notarization archive" + ditto -c -k --keepParent "macos/build/Release/Ghostty.app" "notarization.zip" + + # Here we send the notarization request to the Apple's Notarization service, waiting for the result. + # This typically takes a few seconds inside a CI environment, but it might take more depending on the App + # characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if + # you're curious + echo "Notarize app" + xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait + + # Finally, we need to "attach the staple" to our executable, which will allow our app to be + # validated by macOS even when an internet connection is not available. + echo "Attach staple" + xcrun stapler staple "macos/build/Release/Ghostty.app" + + # Zip up the app + - name: Zip App + run: cd macos/build/Release && zip -9 -r --symlinks ../../../ghostty-macos-universal.zip Ghostty.app + + # Update Blob Storage + - name: Prep R2 Storage + run: | + mkdir blob + mkdir -p blob/${GHOSTTY_BUILD} + cp ghostty-macos-universal.zip blob/${GHOSTTY_BUILD}/ghostty-macos-universal.zip + - name: Upload to R2 + uses: ryand56/r2-upload-action@latest + with: + r2-account-id: ${{ secrets.CF_R2_PR_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_PR_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_PR_SECRET_KEY }} + r2-bucket: ghostty-pr + source-dir: blob + destination-dir: ./ From fd382789f3f85e7d9a0ff5ab50a9379689c3dacd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 16 Mar 2024 14:38:22 -0700 Subject: [PATCH 328/428] ci: PR builds for macOS should be ReleaseSafe --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index c4320cf3ef..acba31adcf 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -46,7 +46,7 @@ jobs: # GhosttyKit is the framework that is built from Zig for our native # Mac app to access. Build this in release mode. - name: Build GhosttyKit - run: nix develop -c zig build -Dstatic=true -Doptimize=ReleaseFast + run: nix develop -c zig build -Dstatic=true -Doptimize=ReleaseSafe # The native app is built with native XCode tooling. This also does # codesigning. IMPORTANT: this must NOT run in a Nix environment. From 533a86777099c466a74a8375227ec67f51863abe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 16 Mar 2024 14:43:41 -0700 Subject: [PATCH 329/428] ci: release PR on sync --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index acba31adcf..48d338b63f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -1,6 +1,6 @@ on: pull_request: - types: [opened, reopened, synchronized] + types: [opened, reopened, synchronize] workflow_dispatch: {} From 1ac0980ea0930383deed0429620836b494e4af11 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 16 Mar 2024 21:46:31 -0700 Subject: [PATCH 330/428] terminal: pruned pages should keep tracked pins in top-left --- src/terminal/PageList.zig | 21 +++++++++++++++ src/terminal/Screen.zig | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c2019fbfe2..22142871ef 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1571,6 +1571,17 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); + // Update any tracked pins that point to this page to point to the + // new first page to the top-left. + var it = self.tracked_pins.keyIterator(); + while (it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != first) continue; + p.page = self.pages.first.?; + p.y = 0; + p.x = 0; + } + // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. @@ -3271,6 +3282,11 @@ test "PageList grow prune scrollback" { // Get our page size const old_page_size = s.page_size; + // Create a tracked pin in the first page + const p = try s.trackPin(s.pin(.{ .screen = .{} }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.first.?); + // Next should create a new page, but it should reuse our first // page since we're at max size. const new = (try s.grow()).?; @@ -3280,6 +3296,11 @@ test "PageList grow prune scrollback" { // Our first should now be page2 and our last should be page1 try testing.expectEqual(page2_node, s.pages.first.?); try testing.expectEqual(page1_node, s.pages.last.?); + + // Our tracked pin should point to the top-left of the first page + try testing.expect(p.page == s.pages.first.?); + try testing.expect(p.x == 0); + try testing.expect(p.y == 0); } test "PageList adjustCapacity to increase styles" { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d22d185b2d..7c7eb9ab2a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2706,6 +2706,63 @@ test "Screen: scrolling moves selection" { } } +test "Screen: scrolling moves viewport" { + 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\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = -2 }); + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents); + } + + { + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); + } +} + +test "Screen: scrolling when viewport is pruned" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 215, 3, 1); + defer s.deinit(); + + // Write some to create scrollback and move back into our scrollback. + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_row = -2 }); + + // Our viewport is now somewhere pinned. Create so much scrollback + // that we prune it. + for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents); + } + + { + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); + } +} + test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; From a69d9507b3a0ccd0896c4ddf452eae6cf7fb48aa Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 17 Mar 2024 10:25:33 -0500 Subject: [PATCH 331/428] build ghostty nix package with ReleaseSafe --- nix/package.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nix/package.nix b/nix/package.nix index b7a96da015..dee192b120 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -132,6 +132,18 @@ in chmod u+rwX -R $ZIG_GLOBAL_CACHE_DIR ''; + buildPhase = '' + runHook preBuild + zig build -Dcpu=baseline -Doptimize=ReleaseSafe -Dversion-string=${finalAttrs.version}-${revision}-nix + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + zig build install -Dcpu=baseline -Doptimize=ReleaseSafe -Dversion-string=${finalAttrs.version}-${revision}-nix --prefix $out + runHook postInstall + ''; + outputs = ["out" "terminfo" "shell_integration"]; postInstall = '' From f0e3516c3491725fd1ccb43b6198b350f5075a90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 10:25:19 -0700 Subject: [PATCH 332/428] terminal: fix off-by-one tracked pin issues when page is pruned --- src/terminal/PageList.zig | 39 ++++++++++++++++++++++++++++++++++++++- src/terminal/Screen.zig | 30 ++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 22142871ef..72cb7fc8e1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1962,7 +1962,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point if (tl.y > p.y) return null; coord.y = p.y - tl.y; } else { - coord.y += tl.page.data.size.rows - tl.y - 1; + coord.y += tl.page.data.size.rows - tl.y; var page_ = tl.page.next; while (page_) |page| : (page_ = page.next) { if (page == p.page) { @@ -2879,6 +2879,43 @@ test "PageList pointFromPin active from prior page" { } } +test "PageList pointFromPin traverse pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + const page = &s.pages.last.?.data; + for (0..page.capacity.rows * 2) |_| { + _ = try s.grow(); + } + + { + const pages = s.totalPages(); + const page_cap = page.capacity.rows; + const expected_y = page_cap * (pages - 2) + 5; + + try testing.expectEqual(point.Point{ + .screen = .{ + .y = expected_y, + .x = 2, + }, + }, s.pointFromPin(.screen, .{ + .page = s.pages.last.?.prev.?, + .y = 5, + .x = 2, + }).?); + } + + // Prior page + { + try testing.expect(s.pointFromPin(.active, .{ + .page = s.pages.first.?, + .y = 0, + .x = 0, + }) == null); + } +} test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 7c7eb9ab2a..ca77ae177a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -479,10 +479,35 @@ pub fn cursorDownScroll(self: *Screen) !void { page_pin.page.data.getCells(self.cursor.page_row), ); } else { + const old_pin = self.cursor.page_pin.*; + // 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).?; + + // If our pin page change it means that the page that the pin + // was on was pruned. In this case, grow() moves the pin to + // the top-left of the new page. This effectively moves it by + // one already, we just need to fix up the x value. + const page_pin = if (old_pin.page == self.cursor.page_pin.page) + self.cursor.page_pin.down(1).? + else reuse: { + var pin = self.cursor.page_pin.*; + pin.x = self.cursor.x; + break :reuse pin; + }; + + // These assertions help catch some pagelist math errors. Our + // x/y should be unchanged after the grow. + if (comptime std.debug.runtime_safety) { + const active = self.pages.pointFromPin( + .active, + page_pin, + ).?.active; + assert(active.x == self.cursor.x); + assert(active.y == self.cursor.y); + } + const page_rac = page_pin.rowAndCell(); self.cursor.page_pin.* = page_pin; self.cursor.page_row = page_rac.row; @@ -2745,6 +2770,7 @@ test "Screen: scrolling when viewport is pruned" { // Our viewport is now somewhere pinned. Create so much scrollback // that we prune it. + try s.testWriteString("\n"); for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -2752,7 +2778,7 @@ test "Screen: scrolling when viewport is pruned" { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } { From bf34582f54e4a8b712c81be15121aa9715427da5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 17 Mar 2024 11:47:32 -0500 Subject: [PATCH 333/428] allow building nix package with different optimizations --- flake.nix | 14 +++++++++++++- nix/package.nix | 15 ++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/flake.nix b/flake.nix index 0536b52470..ad7d4da173 100644 --- a/flake.nix +++ b/flake.nix @@ -53,10 +53,22 @@ }; packages.${system} = rec { - ghostty = pkgs-stable.callPackage ./nix/package.nix { + ghostty-debug = pkgs-stable.callPackage ./nix/package.nix { inherit (pkgs-zig-0-12) zig_0_12; revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "Debug"; }; + ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix { + inherit (pkgs-zig-0-12) zig_0_12; + revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "ReleaseSafe"; + }; + ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix { + inherit (pkgs-zig-0-12) zig_0_12; + revision = self.shortRev or self.dirtyShortRev or "dirty"; + optimize = "ReleaseFast"; + }; + ghostty = ghostty-releasesafe; default = ghostty; }; diff --git a/nix/package.nix b/nix/package.nix index dee192b120..b638831da4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -25,6 +25,7 @@ zig_0_12, pandoc, revision ? "dirty", + optimize ? "Debug", }: let # The Zig hook has no way to select the release type without actual # overriding of the default flags. @@ -34,7 +35,7 @@ # ultimately acted on and has made its way to a nixpkgs implementation, this # can probably be removed in favor of that. zig012Hook = zig_0_12.hook.overrideAttrs { - zig_default_flags = "-Dcpu=baseline -Doptimize=ReleaseFast"; + zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize}"; }; # This hash is the computation of the zigCache fixed-output derivation. This @@ -132,18 +133,6 @@ in chmod u+rwX -R $ZIG_GLOBAL_CACHE_DIR ''; - buildPhase = '' - runHook preBuild - zig build -Dcpu=baseline -Doptimize=ReleaseSafe -Dversion-string=${finalAttrs.version}-${revision}-nix - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - zig build install -Dcpu=baseline -Doptimize=ReleaseSafe -Dversion-string=${finalAttrs.version}-${revision}-nix --prefix $out - runHook postInstall - ''; - outputs = ["out" "terminfo" "shell_integration"]; postInstall = '' From c8a3040519ad1a6f4c78529eec26520fa28e6772 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 21:03:20 -0700 Subject: [PATCH 334/428] terminal: resizing to lt rows should not trim blanks with tracked pin --- src/terminal/PageList.zig | 85 ++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 72cb7fc8e1..90754520d5 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1370,25 +1370,31 @@ fn trimTrailingBlankRows( max: size.CellCountInt, ) size.CellCountInt { var trimmed: size.CellCountInt = 0; - var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows_slice = page.data.rows.ptr(page.data.memory)[0..len]; - for (0..len) |i| { - const rev_i = len - i - 1; - const row = &rows_slice[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..page.data.size.cols]; - - // If the row has any text then we're done. - if (pagepkg.Cell.hasTextAny(cells)) return trimmed; - - // No text, we can trim this row. Because it has - // no text we can also be sure it has no styling - // so we don't need to worry about memory. - page.data.size.rows -= 1; - trimmed += 1; - if (trimmed >= max) return trimmed; + const bl_pin = self.getBottomRight(.screen).?; + var it = bl_pin.rowIterator(.left_up, null); + while (it.next()) |row_pin| { + const cells = row_pin.cells(.all); + + // If the row has any text then we're done. + if (pagepkg.Cell.hasTextAny(cells)) return trimmed; + + // If our tracked pins are in this row then we cannot trim it + // because it implies some sort of importance. If we trimmed this + // we'd invalidate this pin, as well. + var tracked_it = self.tracked_pins.keyIterator(); + while (tracked_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != row_pin.page or + p.y != row_pin.y) continue; + return trimmed; } + + // No text, we can trim this row. Because it has + // no text we can also be sure it has no styling + // so we don't need to worry about memory. + row_pin.page.data.size.rows -= 1; + trimmed += 1; + if (trimmed >= max) return trimmed; } return trimmed; @@ -4308,6 +4314,49 @@ test "PageList resize (no reflow) less rows trims blank lines" { } } +test "PageList resize (no reflow) less rows trims blank lines cursor in blank line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Fill remaining lines with a background color + for (1..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + // Put a tracked pin in a blank line + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?); + defer s.untrackPin(p); + + // Resize + try s.resize(.{ .rows = 2, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.rows); + try testing.expectEqual(@as(usize, 4), s.totalRows()); + + // Our cursor should not move since we trimmed + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pointFromPin(.active, p.*).?); +} + test "PageList resize (no reflow) more rows extends blank lines" { const testing = std.testing; const alloc = testing.allocator; From b76995b5af19ec977db5dea36e84f62a2430be00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 21:13:07 -0700 Subject: [PATCH 335/428] terminal: resizing greater cols without reflow should preserve cols --- src/terminal/PageList.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 90754520d5..4905dee2c1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1247,8 +1247,15 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // pages may not have the capacity for this. If they don't have // the capacity we need to allocate a new page and copy the data. .gt => { + // See the comment in the while loop when setting self.cols + const old_cols = self.cols; + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { + // We need to restore our old cols after we resize because + // we have an assertion on this and we want to be able to + // call this method multiple times. + self.cols = old_cols; try self.resizeWithoutReflowGrowCols(cols, chunk); } From e8a2dc571561a1d5eb6b6207cbde71d507ad9595 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 21:18:24 -0700 Subject: [PATCH 336/428] terminal: cleaner impl of getTopLeft(.active) --- src/terminal/PageList.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 4905dee2c1..a8da11ad00 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2369,17 +2369,18 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { // much faster because we don't need to update the top left. Under // heavy load this makes a measurable difference. .active => active: { - var page = self.pages.last.?; var rem = self.rows; - while (rem > page.data.size.rows) { + var it = self.pages.last; + while (it) |page| : (it = page.prev) { + if (rem <= page.data.size.rows) break :active .{ + .page = page, + .y = page.data.size.rows - rem, + }; + rem -= page.data.size.rows; - page = page.prev.?; // assertion: we always have enough rows for active } - break :active .{ - .page = page, - .y = page.data.size.rows - rem, - }; + unreachable; // assertion: we always have enough rows for active }, }; } From 7e010caea1719f0594fafc442d97f3f0999147ae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 21:55:03 -0700 Subject: [PATCH 337/428] terminal: handle resizing into increased implicit max size --- src/terminal/PageList.zig | 92 +++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a8da11ad00..5b84d9837e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -107,7 +107,12 @@ page_size: usize, /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. -max_size: usize, +explicit_max_size: usize, + +/// This is the minimum max size that we will respect due to the rows/cols +/// of the PageList. We must always be able to fit at least the active area +/// and at least two pages for our algorithms. +min_max_size: usize, /// The list of tracked pins. These are kept up to date automatically. tracked_pins: PinSet, @@ -149,6 +154,29 @@ pub const Viewport = union(enum) { pin, }; +/// Returns the minimum valid "max size" for a given number of rows and cols +/// such that we can fit the active area AND at least two pages. Note we +/// need the two pages for algorithms to work properly (such as grow) but +/// we don't need to fit double the active area. +fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { + // Get our capacity to fit our rows. If the cols are too big, it may + // force less rows than we want meaning we need more than one page to + // represent a viewport. + const cap = try std_capacity.adjust(.{ .cols = cols }); + + // Calculate the number of standard sized pages we need to represent + // an active area. We always need at least two pages so if we can fit + // all our rows in one cap then we say 2, otherwise we do the math. + const pages = if (cap.rows >= rows) 2 else try std.math.divCeil( + usize, + rows, + cap.rows, + ); + assert(pages >= 2); + + return std_size * pages; +} + /// Initialize the page. The top of the first page in the list is always the /// top of the active area of the screen (important knowledge for quickly /// setting up cursors in Screen). @@ -201,14 +229,8 @@ pub fn init( page_list.prepend(page); const page_size = page_buf.len; - // The max size has to be adjusted to at least fit one viewport. - // We use item_size*2 because the active area can always span two - // pages as we scroll, otherwise we'd have to constantly copy in the - // small limit case. - const max_size_actual = @max( - max_size orelse std.math.maxInt(usize), - PagePool.item_size * 2, - ); + // Get our minimum max size, see doc comments for more details. + const min_max_size = try minMaxSize(cols, rows); // We always track our viewport pin to ensure this is never an allocation const viewport_pin = try pool.pins.create(); @@ -223,7 +245,8 @@ pub fn init( .pool_owned = true, .pages = page_list, .page_size = page_size, - .max_size = max_size_actual, + .explicit_max_size = max_size orelse std.math.maxInt(usize), + .min_max_size = min_max_size, .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, @@ -450,7 +473,8 @@ pub fn clone( }, .pages = page_list, .page_size = page_size, - .max_size = self.max_size, + .explicit_max_size = self.explicit_max_size, + .min_max_size = self.min_max_size, .cols = self.cols, .rows = self.rows, .tracked_pins = tracked_pins, @@ -502,6 +526,16 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) !void { if (!opts.reflow) return try self.resizeWithoutReflow(opts); + // Recalculate our minimum max size. This allows grow to work properly + // when increasing beyond our initial minimum max size or explicit max + // size to fit the active area. + const old_min_max_size = self.min_max_size; + self.min_max_size = try minMaxSize( + opts.cols orelse self.cols, + opts.rows orelse self.rows, + ); + errdefer self.min_max_size = old_min_max_size; + // On reflow, the main thing that causes reflow is column changes. If // only rows change, reflow is impossible. So we change our behavior based // on the change of columns. @@ -1148,6 +1182,15 @@ fn reflowUpdateCursor( } fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { + // We only set the new min_max_size if we're not reflowing. If we are + // reflowing, then resize handles this for us. + const old_min_max_size = self.min_max_size; + self.min_max_size = if (!opts.reflow) try minMaxSize( + opts.cols orelse self.cols, + opts.rows orelse self.rows, + ) else old_min_max_size; + errdefer self.min_max_size = old_min_max_size; + if (opts.rows) |rows| { switch (std.math.order(rows, self.rows)) { .eq => {}, @@ -1550,6 +1593,10 @@ pub fn scrollClear(self: *PageList) !void { for (0..non_empty) |_| _ = try self.grow(); } +fn maxSize(self: *const PageList) usize { + return @max(self.explicit_max_size, self.min_max_size); +} + /// Grow the active area by exactly one row. /// /// This may allocate, but also may not if our current page has more @@ -1570,7 +1617,7 @@ pub fn grow(self: *PageList) !?*List.Node { // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first // page. - if (self.page_size + PagePool.item_size > self.max_size) { + if (self.page_size + PagePool.item_size > self.maxSize()) { const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); // Get our first page and reset it to prepare for reuse. @@ -1610,7 +1657,7 @@ pub fn grow(self: *PageList) !?*List.Node { // We should never be more than our max size here because we've // verified the case above. - assert(self.page_size <= self.max_size); + assert(self.page_size <= self.maxSize()); return next_page; } @@ -4569,6 +4616,25 @@ test "PageList resize (no reflow) more rows and less cols" { } } +test "PageList resize (no reflow) more rows and cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Resize to a size that requires more than one page to fit our rows. + const new_cols = 600; + const new_rows = 600; + const cap = try std_capacity.adjust(.{ .cols = new_cols }); + try testing.expect(cap.rows < new_rows); + + try s.resize(.{ .cols = new_cols, .rows = new_rows, .reflow = true }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, new_rows), s.rows); + try testing.expectEqual(@as(usize, new_rows), s.totalRows()); +} + test "PageList resize (no reflow) empty screen" { const testing = std.testing; const alloc = testing.allocator; From e7a2a9bcd16408cde22de72dbecff4f00b68452d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 22:00:15 -0700 Subject: [PATCH 338/428] terminal: resize no reflow must do cols before rows --- src/terminal/PageList.zig | 111 ++++++++++++++++++++------------------ 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5b84d9837e..d51e1df7b3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1191,6 +1191,62 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { ) else old_min_max_size; errdefer self.min_max_size = old_min_max_size; + // Important! We have to do cols first because cols may cause us to + // destroy pages if we're increasing cols which will free up page_size + // so that when we call grow() in the row mods, we won't prune. + if (opts.cols) |cols| { + switch (std.math.order(cols, self.cols)) { + .eq => {}, + + // Making our columns smaller. We always have space for this + // in existing pages so we need to go through the pages, + // resize the columns, and clear any cells that are beyond + // the new size. + .lt => { + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.page.data; + const rows = page.rows.ptr(page.memory); + for (0..page.size.rows) |i| { + const row = &rows[i]; + page.clearCells(row, cols, self.cols); + } + + page.size.cols = cols; + } + + // Update all our tracked pins. If they have an X + // beyond the edge, clamp it. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.x >= cols) p.x = cols - 1; + } + + self.cols = cols; + }, + + // Make our columns larger. This is a bit more complicated because + // pages may not have the capacity for this. If they don't have + // the capacity we need to allocate a new page and copy the data. + .gt => { + // See the comment in the while loop when setting self.cols + const old_cols = self.cols; + + var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + // We need to restore our old cols after we resize because + // we have an assertion on this and we want to be able to + // call this method multiple times. + self.cols = old_cols; + try self.resizeWithoutReflowGrowCols(cols, chunk); + } + + self.cols = cols; + }, + } + } + if (opts.rows) |rows| { switch (std.math.order(rows, self.rows)) { .eq => {}, @@ -1252,58 +1308,9 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { self.rows = rows; }, } - } - - if (opts.cols) |cols| { - switch (std.math.order(cols, self.cols)) { - .eq => {}, - - // Making our columns smaller. We always have space for this - // in existing pages so we need to go through the pages, - // resize the columns, and clear any cells that are beyond - // the new size. - .lt => { - var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); - while (it.next()) |chunk| { - const page = &chunk.page.data; - const rows = page.rows.ptr(page.memory); - for (0..page.size.rows) |i| { - const row = &rows[i]; - page.clearCells(row, cols, self.cols); - } - - page.size.cols = cols; - } - - // Update all our tracked pins. If they have an X - // beyond the edge, clamp it. - var pin_it = self.tracked_pins.keyIterator(); - while (pin_it.next()) |p_ptr| { - const p = p_ptr.*; - if (p.x >= cols) p.x = cols - 1; - } - - self.cols = cols; - }, - - // Make our columns larger. This is a bit more complicated because - // pages may not have the capacity for this. If they don't have - // the capacity we need to allocate a new page and copy the data. - .gt => { - // See the comment in the while loop when setting self.cols - const old_cols = self.cols; - var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); - while (it.next()) |chunk| { - // We need to restore our old cols after we resize because - // we have an assertion on this and we want to be able to - // call this method multiple times. - self.cols = old_cols; - try self.resizeWithoutReflowGrowCols(cols, chunk); - } - - self.cols = cols; - }, + if (comptime std.debug.runtime_safety) { + assert(self.totalRows() >= self.rows); } } } @@ -4616,7 +4623,7 @@ test "PageList resize (no reflow) more rows and less cols" { } } -test "PageList resize (no reflow) more rows and cols" { +test "PageList resize more rows and cols doesn't fit in single std page" { const testing = std.testing; const alloc = testing.allocator; From 07a27072dcc644c18350b95302bb18a0dd51d2d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 17 Mar 2024 22:04:03 -0700 Subject: [PATCH 339/428] inspector: needs to call new PageList.maxSize func --- src/inspector/Inspector.zig | 2 +- src/terminal/PageList.zig | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index d44d6786bd..4f30318fc9 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -528,7 +528,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", pages.max_size); + cimgui.c.igText("%d bytes", pages.maxSize()); } } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d51e1df7b3..0284b34a9e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1600,7 +1600,9 @@ pub fn scrollClear(self: *PageList) !void { for (0..non_empty) |_| _ = try self.grow(); } -fn maxSize(self: *const PageList) usize { +/// Returns the actual max size. This may be greater than the explicit +/// value if the explicit value is less than the min_max_size. +pub fn maxSize(self: *const PageList) usize { return @max(self.explicit_max_size, self.min_max_size); } From 3f0607d6c061b1dcba082d9b257efd5355c70c91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 18 Mar 2024 20:20:08 -0700 Subject: [PATCH 340/428] terminal: PageList rowIterator respects limit row --- src/terminal/PageList.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 0284b34a9e..8b2364bc65 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2164,7 +2164,13 @@ pub const RowIterator = struct { self.chunk = self.page_it.next(); if (self.chunk) |c| self.offset = c.end - 1; } else { - self.offset -= 1; + // If we're at the start of the chunk and its a non-zero + // offset then we've reached a limit. + if (self.offset == chunk.start) { + self.chunk = null; + } else { + self.offset -= 1; + } } }, } From 22c181ca7549214611313af571b5c2181d5d9e57 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 18 Mar 2024 20:25:10 -0700 Subject: [PATCH 341/428] terminal: insertLines uses iterators to handle pages --- src/terminal/Terminal.zig | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ece11342fa..be4d729913 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1255,25 +1255,25 @@ pub fn insertLines(self: *Terminal, count: usize) void { // 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); + const top = self.screen.cursor.page_pin.*; // 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); + const bot = top.down(scroll_amount - 1).?; + var it = bot.rowIterator(.left_up, top); + while (it.next()) |p| { + const dst_p = p.down(adjusted_count).?; + assert(dst_p.page == p.page); // TODO: handle different pages + + const src: *Row = p.rowAndCell().row; + const dst: *Row = dst_p.rowAndCell().row; if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the proper @@ -1297,8 +1297,10 @@ pub fn insertLines(self: *Terminal, count: usize) void { } // Inserted lines should keep our bg color - for (0..adjusted_count) |i| { - const row: *Row = @ptrCast(top + i); + const bot = top.down(adjusted_count - 1).?; + var it = top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const row: *Row = p.rowAndCell().row; // Clear the src row. var page = &self.screen.cursor.page_pin.page.data; From 2b50bd530523287d381068741403193f56599990 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 18 Mar 2024 20:36:20 -0700 Subject: [PATCH 342/428] terminal: deleteLines assertion for same page --- src/terminal/Terminal.zig | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index be4d729913..8b1170c229 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1348,8 +1348,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { // 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; + const top = self.screen.cursor.page_pin.*; // Remaining rows from our cursor to the bottom of the scroll region. const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; @@ -1366,10 +1365,14 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { 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); + const bot = top.down(scroll_amount - 1).?; + var it = top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const src_p = p.down(count).?; + assert(src_p.page == p.page); // TODO: handle different pages + + const src: *Row = src_p.rowAndCell().row; + const dst: *Row = p.rowAndCell().row; if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the proper @@ -1392,9 +1395,11 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { } } - const bottom: [*]Row = top + (rem - 1); - while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { - const row: *Row = @ptrCast(y); + const clear_top = top.down(scroll_amount).?; + const bot = top.down(rem - 1).?; + var it = clear_top.rowIterator(.right_down, bot); + while (it.next()) |p| { + const row: *Row = p.rowAndCell().row; // Clear the src row. var page = &self.screen.cursor.page_pin.page.data; From 5e1f8b6cc405ed4a23c66bdaac777b872d1612df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 18 Mar 2024 20:47:38 -0700 Subject: [PATCH 343/428] terminal: insertLines/deleteLines handle split across pages --- src/terminal/Terminal.zig | 36 +++++++++++++-- src/terminal/page.zig | 92 +++++++++++++++++++-------------------- 2 files changed, 78 insertions(+), 50 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8b1170c229..5331b0d62a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1270,12 +1270,24 @@ pub fn insertLines(self: *Terminal, count: usize) void { var it = bot.rowIterator(.left_up, top); while (it.next()) |p| { const dst_p = p.down(adjusted_count).?; - assert(dst_p.page == p.page); // TODO: handle different pages - const src: *Row = p.rowAndCell().row; const dst: *Row = dst_p.rowAndCell().row; if (!left_right) { + // If the pages are not the same, we need to do a slow copy. + if (dst_p.page != p.page) { + dst_p.page.data.cloneRowFrom( + &p.page.data, + dst, + src, + ) catch |err| { + std.log.warn("TODO: insertLines handle clone error err={}", .{err}); + unreachable; + }; + + continue; + } + // 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.*; @@ -1284,6 +1296,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { continue; } + assert(dst_p.page == p.page); // TODO: handle different pages for left/right + // Left/right scroll margins we have to copy cells, which is much slower... var page = &self.screen.cursor.page_pin.page.data; page.moveCells( @@ -1369,12 +1383,24 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { var it = top.rowIterator(.right_down, bot); while (it.next()) |p| { const src_p = p.down(count).?; - assert(src_p.page == p.page); // TODO: handle different pages - const src: *Row = src_p.rowAndCell().row; const dst: *Row = p.rowAndCell().row; if (!left_right) { + // If the pages are not the same, we need to do a slow copy. + if (src_p.page != p.page) { + p.page.data.cloneRowFrom( + &src_p.page.data, + dst, + src, + ) catch |err| { + std.log.warn("TODO: deleteLines handle clone error err={}", .{err}); + unreachable; + }; + + continue; + } + // 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.*; @@ -1383,6 +1409,8 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { continue; } + assert(src_p.page == p.page); // TODO: handle different pages for left/right + // Left/right scroll margins we have to copy cells, which is much slower... var page = &self.screen.cursor.page_pin.page.data; page.moveCells( diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 65ade4e093..7e7cdbacad 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -240,37 +240,49 @@ pub const Page = struct { const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; - for (rows, other_rows) |*dst_row, *src_row| { - // Copy all the row metadata but keep our cells offset - const cells_offset = dst_row.cells; - dst_row.* = src_row.*; - dst_row.cells = cells_offset; - - const cell_len = @min(self.size.cols, other.size.cols); - const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; - const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; - - // If we have no managed memory in the row, we can just copy. - if (!dst_row.grapheme and !dst_row.styled) { - fastmem.copy(Cell, cells, other_cells); - continue; - } + for (rows, other_rows) |*dst_row, *src_row| try self.cloneRowFrom( + other, + dst_row, + src_row, + ); + } - // We have managed memory, so we have to do a slower copy to - // get all of that right. - for (cells, other_cells) |*dst_cell, *src_cell| { - dst_cell.* = src_cell.*; - if (src_cell.hasGrapheme()) { - dst_cell.content_tag = .codepoint; // required for appendGrapheme - const cps = other.lookupGrapheme(src_cell).?; - for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); - } - if (src_cell.style_id != style.default_id) { - const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; - const md = try self.styles.upsert(self.memory, other_style); - md.ref += 1; - dst_cell.style_id = md.id; - } + /// Clone a single row from another page into this page. + pub fn cloneRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + ) !void { + // Copy all the row metadata but keep our cells offset + const cells_offset = dst_row.cells; + dst_row.* = src_row.*; + dst_row.cells = cells_offset; + + const cell_len = @min(self.size.cols, other.size.cols); + const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; + const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; + + // If we have no managed memory in the row, we can just copy. + if (!dst_row.grapheme and !dst_row.styled) { + fastmem.copy(Cell, cells, other_cells); + return; + } + + // We have managed memory, so we have to do a slower copy to + // get all of that right. + for (cells, other_cells) |*dst_cell, *src_cell| { + dst_cell.* = src_cell.*; + if (src_cell.hasGrapheme()) { + dst_cell.content_tag = .codepoint; // required for appendGrapheme + const cps = other.lookupGrapheme(src_cell).?; + for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); + } + if (src_cell.style_id != style.default_id) { + const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; + const md = try self.styles.upsert(self.memory, other_style); + md.ref += 1; + dst_cell.style_id = md.id; } } } @@ -602,21 +614,9 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const grapheme_map_start = alignBackward( - usize, - layout.total_size - layout.grapheme_map_layout.total_size, - GraphemeMap.base_align - ); - const grapheme_alloc_start = alignBackward( - usize, - grapheme_map_start - layout.grapheme_alloc_layout.total_size, - GraphemeAlloc.base_align - ); - const styles_start = alignBackward( - usize, - grapheme_alloc_start - layout.styles_layout.total_size, - style.Set.base_align - ); + const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); + const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); const available_size = styles_start; const size_per_row = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); @@ -914,7 +914,7 @@ test "Page capacity adjust cols sweep" { var cap = std_capacity; const original_cols = cap.cols; const original_size = Page.layout(cap).total_size; - for (1..original_cols*2) |c| { + for (1..original_cols * 2) |c| { cap = try cap.adjust(.{ .cols = @as(u16, @intCast(c)) }); const adjusted_size = Page.layout(cap).total_size; try testing.expectEqual(original_size, adjusted_size); From 1f62284c26c44084791a66d778793deee7a19994 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 18 Mar 2024 20:57:46 -0700 Subject: [PATCH 344/428] terminal: delete/insertLines uses correct page for clearing --- src/terminal/Terminal.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5331b0d62a..5bfc548725 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1299,7 +1299,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { assert(dst_p.page == p.page); // TODO: handle different pages for left/right // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_pin.page.data; + const page = &p.page.data; page.moveCells( src, self.scrolling_region.left, @@ -1317,7 +1317,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { const row: *Row = p.rowAndCell().row; // Clear the src row. - var page = &self.screen.cursor.page_pin.page.data; + const page = &p.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); @@ -1412,7 +1412,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { assert(src_p.page == p.page); // TODO: handle different pages for left/right // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_pin.page.data; + const page = &p.page.data; page.moveCells( src, self.scrolling_region.left, @@ -1430,7 +1430,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const row: *Row = p.rowAndCell().row; // Clear the src row. - var page = &self.screen.cursor.page_pin.page.data; + const page = &p.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); From d54d7cd5813cc5c98510220c90e184776f774773 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Mon, 18 Mar 2024 15:07:56 -0500 Subject: [PATCH 345/428] terminal: set PageList viewport to active area when cloned As an optimization, the renderer does not attempt to find the cell under the cursor if the viewport is in the scrollback (i.e. not the active area). When the renderer clones the screen state it also clones the PageList, and the cloned PageList has its viewport set to the top of the scrollback. This caused the renderer to never attempt to find the cell under the cursor, which in turn caused cells under the cursor to be improperly highlighted. Instead, when the PageList is cloned initialize its viewport to the active area. --- src/terminal/PageList.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 8b2364bc65..a2918f604e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -306,7 +306,7 @@ pub const Clone = struct { /// Clone this pagelist from the top to bottom (inclusive). /// -/// The viewport is always moved to the top-left. +/// The viewport is always moved to the active area. /// /// The cloned pagelist must contain at least enough rows for the active /// area. If the region specified has less rows than the active area then @@ -478,7 +478,7 @@ pub fn clone( .cols = self.cols, .rows = self.rows, .tracked_pins = tracked_pins, - .viewport = .{ .top = {} }, + .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, }; From 06d944c2926c36088bebdd6335f1ef5c0bc54df6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 08:14:19 -0700 Subject: [PATCH 346/428] terminal: cloneFrom clears destination --- src/terminal/page.zig | 68 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7e7cdbacad..f9ca0ecb86 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -220,9 +220,6 @@ pub const Page = struct { /// If the other page has more columns, the extra columns will be /// truncated. If the other page has fewer columns, the extra columns /// will be zeroed. - /// - /// The current page is assumed to be empty. We will not clear any - /// existing data in the current page. pub fn cloneFrom( self: *Page, other: *const Page, @@ -232,11 +229,6 @@ pub const Page = struct { assert(y_start <= y_end); assert(y_end <= other.size.rows); assert(y_end - y_start <= self.size.rows); - if (comptime std.debug.runtime_safety) { - // The current page must be empty. - assert(self.styles.count(self.memory) == 0); - assert(self.graphemeCount() == 0); - } const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; @@ -254,15 +246,23 @@ pub const Page = struct { dst_row: *Row, src_row: *const Row, ) !void { + const cell_len = @min(self.size.cols, other.size.cols); + const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; + const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; + + // If our destination has styles or graphemes then we need to + // clear some state. + if (dst_row.grapheme or dst_row.styled) { + self.clearCells(dst_row, 0, cells.len); + assert(!dst_row.grapheme); + assert(!dst_row.styled); + } + // Copy all the row metadata but keep our cells offset const cells_offset = dst_row.cells; dst_row.* = src_row.*; dst_row.cells = cells_offset; - const cell_len = @min(self.size.cols, other.size.cols); - const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; - const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; - // If we have no managed memory in the row, we can just copy. if (!dst_row.grapheme and !dst_row.styled) { fastmem.copy(Cell, cells, other_cells); @@ -1278,3 +1278,47 @@ test "Page cloneFrom graphemes" { try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); } } + +test "Page cloneFrom frees dst graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone from page which has no graphemes. + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} From 26321dc1c9556a40e1b51ec0a3820baee539ed3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 08:24:04 -0700 Subject: [PATCH 347/428] termio/exec: only clear above cursor if cursor is not on y=0 --- src/termio/Exec.zig | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1dab117415..e3d08f2b3b 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -478,11 +478,14 @@ pub fn clearScreen(self: *Exec, history: bool) !void { // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { - self.terminal.screen.clearRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, - false, - ); + if (self.terminal.screen.cursor.y > 0) { + self.terminal.screen.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + false, + ); + } + return; } From 1c57bbabdaf309678b9fd535709649219e22876f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 09:25:01 -0700 Subject: [PATCH 348/428] termio/exec: clear screen should erase rows and shift up --- src/terminal/PageList.zig | 8 +++----- src/terminal/Screen.zig | 29 +++++++++++++++++++++++++++++ src/termio/Exec.zig | 3 +-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a2918f604e..19f2512d5c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1871,12 +1871,10 @@ pub fn eraseRows( const old_dst = dst.*; dst.* = src.*; src.* = old_dst; - } - // We don't even bother deleting the data in the swapped rows - // because erasing in this way yields a page that likely will never - // be written to again (its in the past) or it will grow and the - // terminal erase will automatically erase the data. + // Clear the old data in case we reuse these cells. + chunk.page.data.clearCells(src, 0, chunk.page.data.size.cols); + } // Update any tracked pins to shift their y. If it was in the erased // row then we move it to the top of this page. diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ca77ae177a..59748a14c1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2395,6 +2395,35 @@ test "Screen eraseRows history with more lines" { } } +test "Screen eraseRows active partial" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 5, 5, 0); + defer s.deinit(); + + try s.testWriteString("1\n2\n3"); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("1\n2\n3", str); + } + + s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 1 } }); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("3", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("3", str); + } +} + test "Screen: clearPrompt" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index e3d08f2b3b..43708ed2be 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -479,10 +479,9 @@ pub fn clearScreen(self: *Exec, history: bool) !void { // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { if (self.terminal.screen.cursor.y > 0) { - self.terminal.screen.clearRows( + self.terminal.screen.eraseRows( .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, - false, ); } From 56feeb28a8e8c41e01cd0ae5220b878bfffcb1fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 09:44:12 -0700 Subject: [PATCH 349/428] terminal: fullReset should reset cursor style --- src/terminal/Terminal.zig | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5bfc548725..40c475edca 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2236,12 +2236,22 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { /// Full reset pub fn fullReset(self: *Terminal) void { - self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); + // Switch back to primary screen and clear it. We do not restore cursor + // because see the next step... + self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false }); + + // We set the saved cursor to null and then restore. This will force + // our cursor to go back to the default which will also move the cursor + // to the top-left. + self.screen.saved_cursor = null; + self.restoreCursor() catch |err| { + log.warn("restore cursor on primary screen failed err={}", .{err}); + }; + self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); - self.screen.saved_cursor = null; self.screen.clearSelection(); self.screen.kitty_keyboard = .{}; self.screen.protected_mode = .off; @@ -7663,6 +7673,29 @@ test "Terminal: fullReset with a non-empty pen" { const cell = list_cell.cell; try testing.expect(cell.style_id == 0); } + + try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); +} + +test "Terminal: fullReset with a non-empty saved cursor" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 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.saveCursor(); + 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); + } + + try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); } test "Terminal: fullReset origin mode" { From b8d88fd8a208868df0769ddedd49d85079f8c57b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 11:50:31 -0700 Subject: [PATCH 350/428] terminal: deleteLines with zero count should do nothing --- src/terminal/Terminal.zig | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 40c475edca..77e76acc64 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1353,6 +1353,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { /// /// Moves the cursor to the left margin. pub fn deleteLines(self: *Terminal, count_req: usize) void { + // Rare, but happens + if (count_req == 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 @@ -5719,6 +5722,16 @@ test "Terminal: deleteLines left/right scroll region high count" { } } +test "Terminal: deleteLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // This should do nothing + t.setCursorPos(1, 1); + t.deleteLines(0); +} + test "Terminal: default style is empty" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); From 631fdf00a85f7959a960478cf4c7461d03a44a0b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 12:52:21 -0700 Subject: [PATCH 351/428] terminal: style needs to be copied to new page on scroll --- src/terminal/Screen.zig | 40 +++++++++++++++++++++++++++++++++++++-- src/terminal/Terminal.zig | 18 ++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 59748a14c1..4184b40fc7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -20,6 +20,8 @@ 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, @@ -524,9 +526,16 @@ pub fn cursorDownScroll(self: *Screen) !void { } } - // 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) { + // We need to ensure our new page has our style. + self.manualStyleUpdate() catch |err| { + // This should never happen because if we're in a new + // page then we should have space for one style. + log.warn("error updating style on scroll err={}", .{err}); + }; + + // The newly created line needs to be styled according to + // the bg color if it is set. if (self.cursor.style.bgCell()) |blank_cell| { const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); const cells = cell_current - self.cursor.x; @@ -2505,6 +2514,33 @@ test "Screen: scrolling" { } } +test "Screen: scrolling across pages preserves style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 1); + defer s.deinit(); + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + const start_page = &s.pages.pages.last.?.data; + + // Scroll down enough to go to another page + const rem = start_page.capacity.rows - start_page.size.rows + 1; + for (0..rem) |_| try s.cursorDownScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. + const page = &s.pages.pages.last.?.data; + try testing.expect(start_page != page); + + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); +} + test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 77e76acc64..312ff17560 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -559,6 +559,24 @@ fn printCell( .protected = self.screen.cursor.protected, }; + if (comptime std.debug.runtime_safety) { + // We've had bugs around this, so let's add an assertion: every + // style we use should be present in the style table. + if (self.screen.cursor.style_id != style.default_id) { + const page = &self.screen.cursor.page_pin.page.data; + if (page.styles.lookupId( + page.memory, + self.screen.cursor.style_id, + ) == null) { + log.err("can't find style page={X} id={}", .{ + @intFromPtr(&self.screen.cursor.page_pin.page.data), + self.screen.cursor.style_id, + }); + @panic("style not found"); + } + } + } + // Handle the style ref count handling style_ref: { if (prev_style_id != style.default_id) { From a40899fa3ceb175777639a2b7f192e5e07bb84e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 12:54:38 -0700 Subject: [PATCH 352/428] terminal: only reload style if we're on a new page on scroll --- src/terminal/Screen.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4184b40fc7..0d4999f561 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -527,12 +527,14 @@ pub fn cursorDownScroll(self: *Screen) !void { } if (self.cursor.style_id != style.default_id) { - // We need to ensure our new page has our style. - self.manualStyleUpdate() catch |err| { + // We need to ensure our new page has our style. This is a somewhat + // expensive operation so we only do it if our page pin y is on zero, + // which signals we're at the top of a page. + if (self.cursor.page_pin.y == 0) { // This should never happen because if we're in a new // page then we should have space for one style. - log.warn("error updating style on scroll err={}", .{err}); - }; + try self.manualStyleUpdate(); + } // The newly created line needs to be styled according to // the bg color if it is set. From 77362d9aa70c79f62d323b7b2a8d28029d97e32f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 13:00:25 -0700 Subject: [PATCH 353/428] terminal: resize should preserve cursor style ref --- src/terminal/Screen.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0d4999f561..27d1df2349 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -845,6 +845,11 @@ fn resizeInternal( // No matter what we mark our image state as dirty self.kitty_images.dirty = true; + // We store our style so that we can update it later. + const old_style = self.cursor.style; + self.cursor.style = .{}; + try self.manualStyleUpdate(); + // Perform the resize operation. This will update cursor by reference. try self.pages.resize(.{ .rows = rows, @@ -863,6 +868,11 @@ fn resizeInternal( // If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload(); + + // Restore our previous pen. Since the page may have changed we + // reset this here so we can setup our ref. + self.cursor.style = old_style; + try self.manualStyleUpdate(); } /// Set a style attribute for the current cursor. From f67b95136db96892ea0a65ce57f821aaa6569fc7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 20:10:23 -0700 Subject: [PATCH 354/428] terminal: in all cursor move cases, we need to account for page changes --- src/terminal/Screen.zig | 194 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 14 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 27d1df2349..68b40569fc 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -367,8 +367,8 @@ pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.y >= n); const page_pin = self.cursor.page_pin.up(n).?; + self.cursorChangePin(page_pin); 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; @@ -391,8 +391,8 @@ pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { // 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).?; + self.cursorChangePin(page_pin); 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; @@ -422,8 +422,8 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) else self.cursor.page_pin.*; page_pin.x = x; + self.cursorChangePin(page_pin); 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; @@ -467,8 +467,8 @@ pub fn cursorDownScroll(self: *Screen) !void { // 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).?; + self.cursorChangePin(page_pin); 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; @@ -510,8 +510,8 @@ pub fn cursorDownScroll(self: *Screen) !void { assert(active.y == self.cursor.y); } + self.cursorChangePin(page_pin); 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; @@ -527,15 +527,6 @@ pub fn cursorDownScroll(self: *Screen) !void { } if (self.cursor.style_id != style.default_id) { - // We need to ensure our new page has our style. This is a somewhat - // expensive operation so we only do it if our page pin y is on zero, - // which signals we're at the top of a page. - if (self.cursor.page_pin.y == 0) { - // This should never happen because if we're in a new - // page then we should have space for one style. - try self.manualStyleUpdate(); - } - // The newly created line needs to be styled according to // the bg color if it is set. if (self.cursor.style.bgCell()) |blank_cell| { @@ -582,6 +573,42 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void { try self.manualStyleUpdate(); } +/// Always use this to write to cursor.page_pin.*. +/// +/// This specifically handles the case when the new pin is on a different +/// page than the old AND we have a style set. In that case, we must release +/// our old style and upsert our new style since styles are stored per-page. +fn cursorChangePin(self: *Screen, new: Pin) void { + // If we have a style set, then we need to migrate it over to the + // new page. This is expensive so we do everything we can with cheap + // ops to avoid it. + if (self.cursor.style_id == style.default_id or + self.cursor.page_pin.page == new.page) + { + self.cursor.page_pin.* = new; + return; + } + + // Store our old full style so we can reapply it in the new page. + const old_style = self.cursor.style; + + // Clear our old style in the current pin + self.cursor.style = .{}; + self.manualStyleUpdate() catch unreachable; // Removing a style should never fail + + // Update our pin to the new page + self.cursor.page_pin.* = new; + self.cursor.style = old_style; + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor change err={}", .{err}); + self.cursor.style = .{}; + }; +} + /// 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. @@ -2489,6 +2516,145 @@ test "Screen: clearPrompt no prompt" { } } +test "Screen: cursorDown across pages preserves style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 1); + defer s.deinit(); + + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); + } + + // Scroll back to the previous page + s.cursorUp(1); + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); + } + + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); + { + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } + + // Go back down into the next page and we should have that style + s.cursorDown(1); + { + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } +} + +test "Screen: cursorUp across pages preserves style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 1); + defer s.deinit(); + + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); + } + + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); + { + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } + + // Go back down into the prev page and we should have that style + s.cursorUp(1); + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); + + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } +} + +test "Screen: cursorAbsolute across pages preserves style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 1); + defer s.deinit(); + + // Scroll down enough to go to another page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + for (0..rem) |_| try s.cursorDownOrScroll(); + + // We need our page to change for this test o make sense. If this + // assertion fails then the bug is in the test: we should be scrolling + // above enough for a new page to show up. + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page != page); + } + + // Go back up, set a style + try s.setAttribute(.{ .bold = {} }); + { + const page = &s.cursor.page_pin.page.data; + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } + + // Go back down into the prev page and we should have that style + s.cursorAbsolute(1, 1); + { + const page = &s.cursor.page_pin.page.data; + try testing.expect(start_page == page); + + const styleval = page.styles.lookupId( + page.memory, + s.cursor.style_id, + ).?; + try testing.expect(styleval.flags.bold); + } +} test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; From 9e42ee0dc9183ea1e3f0916a5fcabaaff2c65303 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 20:21:12 -0700 Subject: [PATCH 355/428] terminal: all cursorReload scenarios should check style data --- src/terminal/Screen.zig | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 68b40569fc..b4bb984b1b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -452,6 +452,29 @@ pub fn cursorReload(self: *Screen) void { const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; + + // If we have a style, we need to ensure it is in the page because this + // method may also be called after a page change. + if (self.cursor.style_id != style.default_id) { + // We set our ref to null because manualStyleUpdate will refresh it. + // If we had a valid ref and it was zero before, then manualStyleUpdate + // will reload the same ref. + // + // We want to avoid the scenario this was non-null but the pointer + // is now invalid because it pointed to a page that no longer exists. + self.cursor.style_ref = null; + + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor reload err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + }; + } } /// Scroll the active area and keep the cursor at the bottom of the screen. @@ -872,11 +895,6 @@ fn resizeInternal( // No matter what we mark our image state as dirty self.kitty_images.dirty = true; - // We store our style so that we can update it later. - const old_style = self.cursor.style; - self.cursor.style = .{}; - try self.manualStyleUpdate(); - // Perform the resize operation. This will update cursor by reference. try self.pages.resize(.{ .rows = rows, @@ -895,11 +913,6 @@ fn resizeInternal( // If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload(); - - // Restore our previous pen. Since the page may have changed we - // reset this here so we can setup our ref. - self.cursor.style = old_style; - try self.manualStyleUpdate(); } /// Set a style attribute for the current cursor. @@ -1055,6 +1068,10 @@ pub fn manualStyleUpdate(self: *Screen) !void { if (ref.* == 0) { page.styles.remove(page.memory, self.cursor.style_id); } + + // Reset our ID and ref to null since the ref is now invalid. + self.cursor.style_id = 0; + self.cursor.style_ref = null; } // If our new style is the default, just reset to that From 29a9d09bbd165091772bd44135e5850e6f597aee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 19 Mar 2024 21:30:32 -0700 Subject: [PATCH 356/428] terminal: when overwriting wide spacer tail, clear graphemes --- src/terminal/Terminal.zig | 82 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 312ff17560..8096c3fa4c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -525,7 +525,11 @@ fn printCell( assert(self.screen.cursor.x > 0); const wide_cell = self.screen.cursorCellLeft(1); - wide_cell.* = .{ .style_id = self.screen.cursor.style_id }; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + wide_cell[0..1], + ); if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { const head_cell = self.screen.cursorCellEndOfPrev(); head_cell.wide = .narrow; @@ -2839,6 +2843,82 @@ test "Terminal: overwrite grapheme should clear grapheme data" { } } +test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // 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 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, 2), t.screen.cursor.x); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Move back and overwrite wide + t.setCursorPos(1, 1); + try t.print('X'); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} + +test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // 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 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, 2), t.screen.cursor.x); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Move back and overwrite wide + t.setCursorPos(1, 2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); +} + test "Terminal: print writes to bottom if scrolled" { var t = try init(testing.allocator, .{ .cols = 5, .rows = 2 }); defer t.deinit(testing.allocator); From e64d8f53047083e33e65b69f6351522cf843ef2d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 10:20:25 -0700 Subject: [PATCH 357/428] terminal: handles eraseRows that erases our full pagelist --- src/terminal/PageList.zig | 90 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 19f2512d5c..7fe25bdc1f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -524,6 +524,13 @@ pub const Resize = struct { /// Resize /// TODO: docs pub fn resize(self: *PageList, opts: Resize) !void { + if (comptime std.debug.runtime_safety) { + // Resize does not work with 0 values, this should be protected + // upstream + if (opts.cols) |v| assert(v > 0); + if (opts.rows) |v| assert(v > 0); + } + if (!opts.reflow) return try self.resizeWithoutReflow(opts); // Recalculate our minimum max size. This allows grow to work properly @@ -1853,7 +1860,15 @@ pub fn eraseRows( while (it.next()) |chunk| { // If the chunk is a full page, deinit thit page and remove it from // the linked list. - if (chunk.fullPage()) { + if (chunk.fullPage()) full_page: { + // A rare special case is that we're deleting everything + // in our linked list. erasePage requires at least one other + // page so to handle this we break out of this handling and + // do a normal row by row erase. + if (chunk.page.next == null and chunk.page.prev == null) { + break :full_page; + } + self.erasePage(chunk.page); erased += chunk.page.data.size.rows; continue; @@ -1876,6 +1891,17 @@ pub fn eraseRows( chunk.page.data.clearCells(src, 0, chunk.page.data.size.cols); } + // Clear our remaining cells that we didn't shift or swapped + // in case we grow back into them. + for (scroll_amount..chunk.page.data.size.rows) |i| { + const row: *Row = &rows[i]; + chunk.page.data.clearCells( + row, + 0, + chunk.page.data.size.cols, + ); + } + // Update any tracked pins to shift their y. If it was in the erased // row then we move it to the top of this page. var pin_it = self.tracked_pins.keyIterator(); @@ -3972,6 +3998,34 @@ test "PageList erase active regrows automatically" { try testing.expect(s.totalRows() == s.rows); } +test "PageList erase a one-row active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 1, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Write our letter + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + s.eraseRows(.{ .active = .{} }, .{ .active = .{} }); + try testing.expectEqual(s.rows, s.totalRows()); + + // The row should be empty + { + const get = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(@as(u21, 0), get.cell.content.codepoint); + } +} + test "PageList clone" { const testing = std.testing; const alloc = testing.allocator; @@ -4217,6 +4271,40 @@ test "PageList resize (no reflow) less rows" { } } +test "PageList resize (no reflow) one rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + + // This is required for our writing below to work + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write into all rows so we don't get trim behavior + for (0..s.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Resize + try s.resize(.{ .rows = 1, .reflow = false }); + try testing.expectEqual(@as(usize, 1), s.rows); + try testing.expectEqual(@as(usize, 10), s.totalRows()); + { + const pt = s.getCell(.{ .active = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 9, + } }, pt); + } +} + test "PageList resize (no reflow) less rows cursor on bottom" { const testing = std.testing; const alloc = testing.allocator; From 91602a4ce70c21c6991240eb743d01c314282231 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 10:31:01 -0700 Subject: [PATCH 358/428] terminal: Screen scroll test and handle single row screens --- src/terminal/Screen.zig | 70 ++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b4bb984b1b..958ba98b97 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -484,24 +484,29 @@ pub fn cursorDownScroll(self: *Screen) !void { // 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).?; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + // If we have a single-row screen, we have no rows to shift + // so our cursor is in the correct place we just have to clear + // the cells. + if (self.pages.rows > 1) { + // 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).?; + self.cursorChangePin(page_pin); + const page_rac = page_pin.rowAndCell(); + 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_pin.page.data, self.cursor.page_row, - page_pin.page.data.getCells(self.cursor.page_row), + self.cursor.page_pin.page.data.getCells(self.cursor.page_row), ); } else { const old_pin = self.cursor.page_pin.*; @@ -2709,6 +2714,47 @@ test "Screen: scrolling" { } } +test "Screen: scrolling with a single-row screen no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 1, 0); + defer s.deinit(); + try s.testWriteString("1ABCD"); + + // Scroll down, should still be bottom + try s.cursorDownScroll(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +test "Screen: scrolling with a single-row screen with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 1, 1); + defer s.deinit(); + try s.testWriteString("1ABCD"); + + // Scroll down, should still be bottom + try s.cursorDownScroll(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + + s.scroll(.{ .delta_row = -1 }); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; From 9d826d8837f1d8d4bfec048158e106cac4cb4ad0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 16:18:29 -0700 Subject: [PATCH 359/428] terminal: add assertion for trackPin as commented --- src/terminal/PageList.zig | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 7fe25bdc1f..fc4683f792 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1986,7 +1986,7 @@ pub fn pin(self: *const PageList, pt: point.Point) ?Pin { /// pin points to is removed completely, the tracked pin will be updated /// to the top-left of the screen. pub fn trackPin(self: *PageList, p: Pin) !*Pin { - // TODO: assert pin is valid + if (comptime std.debug.runtime_safety) assert(self.pinIsValid(p)); // Create our tracked pin const tracked = try self.pool.pins.create(); @@ -2012,6 +2012,20 @@ pub fn countTrackedPins(self: *const PageList) usize { return self.tracked_pins.count(); } +/// Checks if a pin is valid for this pagelist. This is a very slow and +/// expensive operation since we traverse the entire linked list in the +/// worst case. Only for runtime safety/debug. +fn pinIsValid(self: *const PageList, p: Pin) bool { + var it = self.pages.first; + while (it) |page| : (it = page.next) { + if (page != p.page) continue; + return p.y < page.data.size.rows and + p.x < page.data.size.cols; + } + + return false; +} + /// Returns the viewport for the given pin, prefering to pin to /// "active" if the pin is within the active area. fn pinIsActive(self: *const PageList, p: Pin) bool { From 3f23de43738e8e02d33ed0c08875fd7af7680df3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 16:19:46 -0700 Subject: [PATCH 360/428] terminal: remove completed todo --- src/terminal/Terminal.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8096c3fa4c..5da38726b1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3,10 +3,6 @@ //! 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; From b0c0307dda6a8b6088b956b0fa42c2c14a34aa6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 16:20:30 -0700 Subject: [PATCH 361/428] terminal: eraseDisplay complete needs to delete kitty images --- src/terminal/Terminal.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5da38726b1..1a6cad8543 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1793,8 +1793,11 @@ pub fn eraseDisplay( self.screen.cursor.pending_wrap = false; // Clear all Kitty graphics state for this screen - // TODO - // self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + self.screen.kitty_images.delete( + self.screen.alloc, + self, + .{ .all = true }, + ); }, .complete => { From a7f74a9dd6756f619549935d6aea115f0dd0c788 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 16:21:36 -0700 Subject: [PATCH 362/428] terminal: remove unnecessary todo --- src/terminal/Terminal.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1a6cad8543..03285ed22c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1903,8 +1903,6 @@ pub fn decaln(self: *Terminal) !void { 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(); From cc75cc9980062a3324652436beba17256e3e557a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 20:17:35 -0700 Subject: [PATCH 363/428] terminal: deleteChars should not split wide char cursor x --- src/terminal/Terminal.zig | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 03285ed22c..20f1d24a5f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1604,6 +1604,16 @@ pub fn deleteChars(self: *Terminal, count: usize) void { self.screen.clearCells(page, self.screen.cursor.page_row, wide[0..2]); } + // If our first cell is a wide char then we need to also clear + // the spacer tail following it. + if (x[0].wide == .wide) { + self.screen.clearCells( + page, + self.screen.cursor.page_row, + x[0..2], + ); + } + while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { const src: *Cell = @ptrCast(x + count); const dst: *Cell = @ptrCast(x); @@ -6539,7 +6549,7 @@ test "Terminal: deleteChars inside scroll region" { } } -test "Terminal: deleteChars split wide character" { +test "Terminal: deleteChars split wide character from spacer tail" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); @@ -6555,6 +6565,29 @@ test "Terminal: deleteChars split wide character" { } } +test "Terminal: deleteChars split wide character from wide" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("橋123"); + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + 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, '1'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); From dfa5b2e6fc6d153136a46d96933437a31f343439 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 20:31:49 -0700 Subject: [PATCH 364/428] terminal: pagelist handle scenario where reflow erases all pages --- src/terminal/PageList.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index fc4683f792..246d1df622 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -825,8 +825,18 @@ fn reflowPage( while (src_cursor.page_row.wrap_continuation) { // If this entire page was continuations then we can remove it. if (src_cursor.y == src_cursor.page.size.rows - 1) { - self.pages.remove(initial_node); - self.destroyPage(initial_node); + // If this is the last page, then we need to insert an empty + // page so that erasePage works. This is a rare scenario that + // can happen in no-scrollback pages where EVERY line is + // a continuation. + if (initial_node.prev == null and initial_node.next == null) { + const cap = try std_capacity.adjust(.{ .cols = cols }); + const node = try self.createPage(cap); + self.pages.insertAfter(initial_node, node); + } + + self.erasePage(initial_node); + assert(self.page_size <= self.maxSize()); return; } From 565a5a60481079decc9c71ac28fae242daaf5a4f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 20:44:39 -0700 Subject: [PATCH 365/428] terminal: bitmap allocator handles 64-chunk sized allocs --- src/terminal/bitmap_allocator.zig | 46 ++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 9d45327cb4..ffa4416478 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -175,7 +175,7 @@ fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { // but unsure. Contributor friendly: let's benchmark and improve this! // TODO: handle large chunks - assert(n < @bitSizeOf(u64)); + assert(n <= @bitSizeOf(u64)); for (bitmaps, 0..) |*bitmap, idx| { // Shift the bitmap to find `n` sequential free chunks. @@ -237,6 +237,35 @@ test "findFreeChunks multiple found" { ); } +test "findFreeChunks exactly 64 chunks" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 64).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + +// test "findFreeChunks larger than 64 chunks" { +// const testing = std.testing; +// +// var bitmaps = [_]u64{ +// 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, +// 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, +// }; +// const idx = findFreeChunks(&bitmaps, 65).?; +// try testing.expectEqual( +// 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, +// bitmaps[0], +// ); +// try testing.expectEqual(@as(usize, 0), idx); +// } + test "BitmapAllocator layout" { const Alloc = BitmapAllocator(4); const cap = 64 * 4; @@ -322,3 +351,18 @@ test "BitmapAllocator alloc non-byte multi-chunk" { const ptr3 = try bm.alloc(u21, buf, 1); try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); } +// +// test "BitmapAllocator alloc large" { +// const Alloc = BitmapAllocator(2); +// const cap = 256; +// +// const testing = std.testing; +// const alloc = testing.allocator; +// const layout = Alloc.layout(cap); +// const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); +// defer alloc.free(buf); +// +// var bm = Alloc.init(OffsetBuf.init(buf), layout); +// const ptr = try bm.alloc(u8, buf, 128); +// ptr[0] = 'A'; +// } From 1949b2b177feb5e0fae338517f2b03cfc114e320 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 20 Mar 2024 21:06:53 -0700 Subject: [PATCH 366/428] terminal: BitmapAllocator supports allocations across bitmaps --- src/terminal/bitmap_allocator.zig | 151 +++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 36 deletions(-) diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index ffa4416478..7b3a198be4 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -105,14 +105,25 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { const chunks = self.chunks.ptr(base); const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size); - // From the chunk index, we can find the bitmap index - const bitmap_idx = @divFloor(chunk_idx, 64); + // From the chunk index, we can find the starting bitmap index + // and the bit within the last bitmap. + var bitmap_idx = @divFloor(chunk_idx, 64); const bitmap_bit = chunk_idx % 64; - - // Set the bitmap to mark the chunks as free const bitmaps = self.bitmap.ptr(base); + + // If our chunk count is over 64 then we need to handle the + // case where we have to mark multiple bitmaps. + if (chunk_count > 64) { + const bitmaps_full = @divFloor(chunk_count, 64); + for (0..bitmaps_full) |i| bitmaps[bitmap_idx + i] = std.math.maxInt(u64); + bitmap_idx += bitmaps_full; + } + + // Set the bitmap to mark the chunks as free. Note we have to + // do chunk_count % 64 to handle the case where our chunk count + // is using multiple bitmaps. const bitmap = &bitmaps[bitmap_idx]; - for (0..chunk_count) |i| { + for (0..chunk_count % 64) |i| { const mask = @as(u64, 1) << @intCast(bitmap_bit + i); bitmap.* |= mask; } @@ -174,9 +185,48 @@ fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { // I'm not a bit twiddling expert. Perhaps even SIMD could be used here // but unsure. Contributor friendly: let's benchmark and improve this! - // TODO: handle large chunks - assert(n <= @bitSizeOf(u64)); + // Large chunks require special handling. In this case we look for + // divFloor sequential chunks that are maxInt, then look for the mod + // normally in the next bitmap. + if (n > @bitSizeOf(u64)) { + const div = @divFloor(n, @bitSizeOf(u64)); + const mod = n % @bitSizeOf(u64); + var seq: usize = 0; + for (bitmaps, 0..) |*bitmap, idx| { + // If we aren't fully empty then reset the sequence + if (bitmap.* != std.math.maxInt(u64)) { + seq = 0; + continue; + } + + // If we haven't reached the sequence count we're looking for + // then add one and continue, we're still accumulating blanks. + if (seq != div) { + seq += 1; + continue; + } + + // We've reached the seq count see if this has mod starting empty + // blanks. + const final = @as(u64, std.math.maxInt(u64)) >> @intCast(64 - mod); + if (bitmap.* & final == 0) { + // No blanks, reset. + seq = 0; + continue; + } + + // Found! Set all in our sequence to full and mask our final. + const start_idx = idx - seq; + for (start_idx..idx) |i| bitmaps[i] = 0; + bitmap.* ^= final; + + return (start_idx * 64); + } + + return null; + } + assert(n <= @bitSizeOf(u64)); for (bitmaps, 0..) |*bitmap, idx| { // Shift the bitmap to find `n` sequential free chunks. var shifted: u64 = bitmap.*; @@ -251,20 +301,48 @@ test "findFreeChunks exactly 64 chunks" { try testing.expectEqual(@as(usize, 0), idx); } -// test "findFreeChunks larger than 64 chunks" { -// const testing = std.testing; -// -// var bitmaps = [_]u64{ -// 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, -// 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, -// }; -// const idx = findFreeChunks(&bitmaps, 65).?; -// try testing.expectEqual( -// 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, -// bitmaps[0], -// ); -// try testing.expectEqual(@as(usize, 0), idx); -// } +test "findFreeChunks larger than 64 chunks" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 65).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111110, + bitmaps[1], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "findFreeChunks larger than 64 chunks not at beginning" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 65).?; + try testing.expectEqual( + 0b11111111_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[1], + ); + try testing.expectEqual( + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111110, + bitmaps[2], + ); + try testing.expectEqual(@as(usize, 64), idx); +} test "BitmapAllocator layout" { const Alloc = BitmapAllocator(4); @@ -351,18 +429,19 @@ test "BitmapAllocator alloc non-byte multi-chunk" { const ptr3 = try bm.alloc(u21, buf, 1); try testing.expectEqual(@intFromPtr(ptr.ptr), @intFromPtr(ptr3.ptr)); } -// -// test "BitmapAllocator alloc large" { -// const Alloc = BitmapAllocator(2); -// const cap = 256; -// -// const testing = std.testing; -// const alloc = testing.allocator; -// const layout = Alloc.layout(cap); -// const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); -// defer alloc.free(buf); -// -// var bm = Alloc.init(OffsetBuf.init(buf), layout); -// const ptr = try bm.alloc(u8, buf, 128); -// ptr[0] = 'A'; -// } + +test "BitmapAllocator alloc large" { + const Alloc = BitmapAllocator(2); + const cap = 256; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(OffsetBuf.init(buf), layout); + const ptr = try bm.alloc(u8, buf, 129); + ptr[0] = 'A'; + bm.free(buf, ptr); +} From 3513b1cdc2812f80713b0b9026a80d654e8c1ec9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 09:37:44 -0700 Subject: [PATCH 367/428] terminal: properly clear style in error scenario --- src/terminal/Screen.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 958ba98b97..659219aa9c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -634,6 +634,8 @@ fn cursorChangePin(self: *Screen, new: Pin) void { // gracefully back to the default style. log.err("failed to update style on cursor change err={}", .{err}); self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; }; } From 2d8810b4bee4172f11a3f1243f46f8d3a0040cee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 09:42:13 -0700 Subject: [PATCH 368/428] terminal: clear styles properly for clearing wide spacers --- src/terminal/Terminal.zig | 58 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 20f1d24a5f..c89759be32 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -510,7 +510,11 @@ fn printCell( 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 }; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + spacer_cell[0..1], + ); if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { const head_cell = self.screen.cursorCellEndOfPrev(); head_cell.wide = .narrow; @@ -2500,6 +2504,58 @@ test "Terminal: print over wide spacer tail" { } } +test "Terminal: print over wide char with bold" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .bold = {} }); + try t.print(0x1F600); // Smiley face + // 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)); + } + + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone + { + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + } +} + +test "Terminal: print over wide char with bg color" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.print(0x1F600); // Smiley face + // 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)); + } + + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone + { + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + } +} + test "Terminal: print multicodepoint grapheme, disabled mode 2027" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); From 8142eb96787e0e721b0c2eea600b8f4e49489f5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 15:04:16 -0700 Subject: [PATCH 369/428] terminal: moveCell handles graphemes, clears source --- src/terminal/page.zig | 128 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index f9ca0ecb86..37eaeca372 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -335,17 +335,33 @@ pub const Page = struct { const src_cells = src_row.cells.ptr(self.memory)[src_left .. src_left + len]; const dst_cells = dst_row.cells.ptr(self.memory)[dst_left .. dst_left + len]; - // If src has no graphemes, this is very fast. + // Clear our destination now matter what + self.clearCells(dst_row, dst_left, dst_left + len); + + // If src has no graphemes, this is very fast because we can + // just copy the cells directly because every other attribute + // is position-independent. const src_grapheme = src_row.grapheme or grapheme: { for (src_cells) |c| if (c.hasGrapheme()) break :grapheme true; break :grapheme false; }; if (!src_grapheme) { fastmem.copy(Cell, dst_cells, src_cells); - return; + } else { + // Source has graphemes, meaning we have to do a slower + // cell by cell copy. + for (src_cells, dst_cells) |*src, *dst| { + dst.* = src.*; + if (!src.hasGrapheme()) continue; + + // Required for moveGrapheme assertions + dst.content_tag = .codepoint; + self.moveGrapheme(src, dst); + } } - @panic("TODO: grapheme move"); + // Clear our source row now that the copy is complete + self.clearCells(src_row, src_left, src_left + len); } /// Clear the cells in the given row. This will reclaim memory used @@ -453,6 +469,24 @@ pub const Page = struct { return slice.offset.ptr(self.memory)[0..slice.len]; } + /// Move the graphemes from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + pub fn moveGrapheme(self: *const Page, src: *Cell, dst: *Cell) void { + if (comptime std.debug.runtime_safety) { + assert(src.hasGrapheme()); + assert(!dst.hasGrapheme()); + } + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + src.content_tag = .codepoint; + } + /// Clear the graphemes for a given cell. pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { if (comptime std.debug.runtime_safety) assert(cell.hasGrapheme()); @@ -1322,3 +1356,91 @@ test "Page cloneFrom frees dst graphemes" { } try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); } + +test "Page moveCells text-only" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.capacity.cols); + + // New rows should have text + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + } + + // Old row should be blank + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} + +test "Page moveCells graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + const original_count = page.graphemeCount(); + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.capacity.cols); + try testing.expectEqual(original_count, page.graphemeCount()); + + // New rows should have text + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + try testing.expectEqualSlices( + u21, + &.{0x0A}, + page.lookupGrapheme(rac.cell).?, + ); + } + + // Old row should be blank + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} From 1be06e8f3fa4e82e4da35aad17ba17e5589d32b1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 16:31:18 -0700 Subject: [PATCH 370/428] terminal: add page.verifyIntegrity function --- src/terminal/page.zig | 306 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 37eaeca372..b72d3b00fa 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const assert = std.debug.assert; const testing = std.testing; const fastmem = @import("../fastmem.zig"); @@ -17,6 +18,8 @@ const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const alignForward = std.mem.alignForward; const alignBackward = std.mem.alignBackward; +const log = std.log.scoped(.page); + /// The allocator to use for multi-codepoint grapheme data. We use /// a chunk size of 4 codepoints. It'd be best to set this empirically /// but it is currently set based on vibes. My thinking around 4 codepoints @@ -171,6 +174,138 @@ pub const Page = struct { self.* = undefined; } + pub const IntegrityError = error{ + UnmarkedGraphemeRow, + MarkedGraphemeRow, + MissingGraphemeData, + InvalidGraphemeCount, + MissingStyle, + UnmarkedStyleRow, + MismatchedStyleRef, + InvalidStyleCount, + }; + + /// Verifies the integrity of the page data. This is not fast, + /// but it is useful for assertions, deserialization, etc. The + /// allocator is only used for temporary allocations -- all memory + /// is freed before this function returns. + /// + /// Integrity errors are also logged as warnings. + pub fn verifyIntegrity(self: *Page, alloc_gpa: Allocator) !void { + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var graphemes_seen: usize = 0; + var styles_seen = std.AutoHashMap(style.Id, usize).init(alloc); + defer styles_seen.deinit(); + + const rows = self.rows.ptr(self.memory)[0..self.size.rows]; + for (rows, 0..) |*row, y| { + const graphemes_start = graphemes_seen; + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells, 0..) |*cell, x| { + if (cell.hasGrapheme()) { + // If a cell has grapheme data, it must be present in + // the grapheme map. + _ = self.lookupGrapheme(cell) orelse { + log.warn( + "page integrity violation y={} x={} grapheme data missing", + .{ y, x }, + ); + return IntegrityError.MissingGraphemeData; + }; + + graphemes_seen += 1; + } + + if (cell.style_id != style.default_id) { + // If a cell has a style, it must be present in the styles + // set. + _ = self.styles.lookupId( + self.memory, + cell.style_id, + ) orelse { + log.warn( + "page integrity violation y={} x={} style missing id={}", + .{ y, x, cell.style_id }, + ); + return IntegrityError.MissingStyle; + }; + + if (!row.styled) { + log.warn( + "page integrity violation y={} x={} row not marked as styled", + .{ y, x }, + ); + return IntegrityError.UnmarkedStyleRow; + } + + const gop = try styles_seen.getOrPut(cell.style_id); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + } + + // Check row grapheme data + if (graphemes_seen > graphemes_start) { + // If a cell in a row has grapheme data, the row must + // be marked as having grapheme data. + if (!row.grapheme) { + log.warn( + "page integrity violation y={} grapheme data but row not marked", + .{y}, + ); + return IntegrityError.UnmarkedGraphemeRow; + } + } else { + // If no cells in a row have grapheme data, the row must + // not be marked as having grapheme data. + if (row.grapheme) { + log.warn( + "page integrity violation y={} row marked but no grapheme data", + .{y}, + ); + return IntegrityError.MarkedGraphemeRow; + } + } + } + + // Our graphemes seen should exactly match the grapheme count + if (graphemes_seen != self.graphemeCount()) { + log.warn( + "page integrity violation grapheme count mismatch expected={} actual={}", + .{ graphemes_seen, self.graphemeCount() }, + ); + return IntegrityError.InvalidGraphemeCount; + } + + // Our unique styles seen should exactly match the style count. + if (styles_seen.count() != self.styles.count(self.memory)) { + log.warn( + "page integrity violation style count mismatch expected={} actual={}", + .{ styles_seen.count(), self.styles.count(self.memory) }, + ); + return IntegrityError.InvalidStyleCount; + } + + // Verify all our styles have the correct ref count. + { + var it = styles_seen.iterator(); + while (it.next()) |entry| { + const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; + const md = self.styles.upsert(self.memory, style_val) catch unreachable; + if (md.ref != entry.value_ptr.*) { + log.warn( + "page integrity violation style ref count mismatch id={} expected={} actual={}", + .{ entry.key_ptr.*, entry.value_ptr.*, md.ref }, + ); + return IntegrityError.MismatchedStyleRef; + } + } + } + } + /// Clone the contents of this page. This will allocate new memory /// using the page allocator. If you want to manage memory manually, /// use cloneBuf. @@ -1444,3 +1579,174 @@ test "Page moveCells graphemes" { ); } } + +test "Page verifyIntegrity graphemes good" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity grapheme row not marked" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Make invalid by unmarking the row + page.getRow(0).grapheme = false; + + try testing.expectError( + Page.IntegrityError.UnmarkedGraphemeRow, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity text row marked as grapheme" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + // Make invalid by unmarking the row + page.getRow(0).grapheme = true; + + try testing.expectError( + Page.IntegrityError.MarkedGraphemeRow, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity styles good" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const md = try page.styles.upsert(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = md.id, + }; + md.ref += 1; + } + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity styles ref count mismatch" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const md = try page.styles.upsert(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = md.id, + }; + md.ref += 1; + } + + // Miss a ref + md.ref -= 1; + + try testing.expectError( + Page.IntegrityError.MismatchedStyleRef, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity styles extra" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const md = try page.styles.upsert(page.memory, .{ .flags = .{ + .bold = true, + } }); + + _ = try page.styles.upsert(page.memory, .{ .flags = .{ + .italic = true, + } }); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = md.id, + }; + md.ref += 1; + } + + try testing.expectError( + Page.IntegrityError.InvalidStyleCount, + page.verifyIntegrity(testing.allocator), + ); +} From 1649641d185351f5b18ed70cce3777e7d19a1597 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 16:53:42 -0700 Subject: [PATCH 371/428] terminal: add some integrity assertions --- src/terminal/Screen.zig | 2 ++ src/terminal/page.zig | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 659219aa9c..a3fd22b5d5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -848,6 +848,7 @@ pub fn clearPrompt(self: *Screen) void { while (clear_it.next()) |p| { const row = p.rowAndCell().row; p.page.data.clearCells(row, 0, p.page.data.size.cols); + p.page.data.assertIntegrity(); } } } @@ -1133,6 +1134,7 @@ pub fn manualStyleUpdate(self: *Screen) !void { /// Append a grapheme to the given cell within the current cursor row. pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { + defer self.cursor.page_pin.page.data.assertIntegrity(); self.cursor.page_pin.page.data.appendGrapheme( self.cursor.page_row, cell, diff --git a/src/terminal/page.zig b/src/terminal/page.zig index b72d3b00fa..2b36ed92ed 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -185,6 +185,15 @@ pub const Page = struct { InvalidStyleCount, }; + /// A helper that can be used to assert the integrity of the page + /// when runtime safety is enabled. This is a no-op when runtime + /// safety is disabled. This uses the libc allocator. + pub fn assertIntegrity(self: *Page) void { + if (comptime std.debug.runtime_safety) { + self.verifyIntegrity(std.heap.c_allocator) catch unreachable; + } + } + /// Verifies the integrity of the page data. This is not fast, /// but it is useful for assertions, deserialization, etc. The /// allocator is only used for temporary allocations -- all memory @@ -280,6 +289,31 @@ pub const Page = struct { return IntegrityError.InvalidGraphemeCount; } + // There is allowed to be exactly one zero ref count style for + // the active style. If we see this, we should add it to our seen + // styles so the math is correct. + { + const id_map = self.styles.id_map.map(self.memory); + var it = id_map.iterator(); + while (it.next()) |entry| { + const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; + const md = self.styles.upsert(self.memory, style_val) catch unreachable; + if (md.ref == 0) { + const gop = try styles_seen.getOrPut(entry.key_ptr.*); + if (gop.found_existing) { + log.warn( + "page integrity violation zero ref style seen multiple times id={}", + .{entry.key_ptr.*}, + ); + return IntegrityError.MismatchedStyleRef; + } + + gop.value_ptr.* = 0; + break; + } + } + } + // Our unique styles seen should exactly match the style count. if (styles_seen.count() != self.styles.count(self.memory)) { log.warn( @@ -1729,9 +1763,10 @@ test "Page verifyIntegrity styles extra" { .bold = true, } }); - _ = try page.styles.upsert(page.memory, .{ .flags = .{ + const md2 = try page.styles.upsert(page.memory, .{ .flags = .{ .italic = true, } }); + md2.ref += 1; // Write for (0..page.capacity.cols) |x| { From 2e9cc7520672e941705329b1b2e0baa6cd674474 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 17:03:15 -0700 Subject: [PATCH 372/428] terminal: add integrity checks throughout PageList --- src/terminal/PageList.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 246d1df622..0531fda580 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -376,6 +376,7 @@ pub fn clone( &page_size, ); assert(page.data.capacity.rows >= chunk.page.data.capacity.rows); + defer page.data.assertIntegrity(); page.data.size.rows = chunk.page.data.size.rows; try page.data.cloneFrom( &chunk.page.data, @@ -867,6 +868,7 @@ fn reflowPage( // The new page always starts with a size of 1 because we know we have // at least one row to copy from the src. const dst_node = try self.createPage(cap); + defer dst_node.data.assertIntegrity(); dst_node.data.size.rows = 1; var dst_cursor = ReflowCursor.init(&dst_node.data); dst_cursor.copyRowMetadata(src_cursor.page_row); @@ -1223,6 +1225,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { const page = &chunk.page.data; + defer page.assertIntegrity(); const rows = page.rows.ptr(page.memory); for (0..page.size.rows) |i| { const row = &rows[i]; @@ -1373,6 +1376,7 @@ fn resizeWithoutReflowGrowCols( var copied: usize = 0; while (copied < page.size.rows) { const new_page = try self.createPage(cap); + defer new_page.data.assertIntegrity(); // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. @@ -1467,6 +1471,7 @@ fn trimTrailingBlankRows( // no text we can also be sure it has no styling // so we don't need to worry about memory. row_pin.page.data.size.rows -= 1; + row_pin.page.data.assertIntegrity(); trimmed += 1; if (trimmed >= max) return trimmed; } @@ -1635,6 +1640,7 @@ pub fn grow(self: *PageList) !?*List.Node { if (last.data.capacity.rows > last.data.size.rows) { // Fast path: we have capacity in the last page. last.data.size.rows += 1; + last.data.assertIntegrity(); return null; } @@ -1671,6 +1677,7 @@ pub fn grow(self: *PageList) !?*List.Node { // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. + first.data.assertIntegrity(); return first; } @@ -1684,6 +1691,7 @@ pub fn grow(self: *PageList) !?*List.Node { // We should never be more than our max size here because we've // verified the case above. assert(self.page_size <= self.maxSize()); + next_page.data.assertIntegrity(); return next_page; } @@ -1755,6 +1763,7 @@ pub fn adjustCapacity( self.pages.remove(page); self.destroyPage(page); + new_page.data.assertIntegrity(); return new_page; } @@ -1884,6 +1893,9 @@ pub fn eraseRows( continue; } + // We are modifying our chunk so make sure it is in a good state. + defer chunk.page.data.assertIntegrity(); + // The chunk is not a full page so we need to move the rows. // This is a cheap operation because we're just moving cell offsets, // not the actual cell contents. From 731f9173503621945fc0cd6dc9b03afea4ece012 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 17:20:33 -0700 Subject: [PATCH 373/428] terminal: add Screen integrity checks, pepper them through cursors --- src/terminal/Screen.zig | 66 +++++++++++++++++++++++++++++++++++++++-- src/terminal/style.zig | 9 ++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a3fd22b5d5..0434a66aa5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -173,6 +173,42 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } +/// Assert that the screen is in a consistent state. This doesn't check +/// all pages in the page list because that is SO SLOW even just for +/// tests. This only asserts the screen specific data so callers should +/// ensure they're also calling page integrity checks if necessary. +pub fn assertIntegrity(self: *const Screen) void { + if (comptime std.debug.runtime_safety) { + assert(self.cursor.x < self.pages.cols); + assert(self.cursor.y < self.pages.rows); + + if (self.cursor.style_id == style.default_id) { + // If our style is default, we should have no refs. + assert(self.cursor.style.default()); + assert(self.cursor.style_ref == null); + } else { + // If our style is not default, we should have a ref. + assert(!self.cursor.style.default()); + assert(self.cursor.style_ref != null); + + // Further, the ref should be valid within the current page. + const page = &self.cursor.page_pin.page.data; + const page_style = page.styles.lookupId( + page.memory, + self.cursor.style_id, + ).?.*; + assert(self.cursor.style.eql(page_style)); + + // The metadata pointer should be equal to the ref. + const md = page.styles.upsert( + page.memory, + page_style, + ) catch unreachable; + assert(&md.ref == self.cursor.style_ref.?); + } + } +} + /// Clone the screen. /// /// This will copy: @@ -309,13 +345,15 @@ pub fn clonePool( }; } else null; - return .{ + const result: Screen = .{ .alloc = alloc, .pages = pages, .no_scrollback = self.no_scrollback, .cursor = cursor, .selection = sel, }; + result.assertIntegrity(); + return result; } pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { @@ -343,6 +381,7 @@ pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { /// 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); + defer self.assertIntegrity(); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); self.cursor.page_cell = @ptrCast(cell + n); @@ -353,6 +392,7 @@ pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { /// Move the cursor left. pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.x >= n); + defer self.assertIntegrity(); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); self.cursor.page_cell = @ptrCast(cell - n); @@ -365,6 +405,7 @@ pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { /// 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); + defer self.assertIntegrity(); const page_pin = self.cursor.page_pin.up(n).?; self.cursorChangePin(page_pin); @@ -376,6 +417,7 @@ pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { assert(self.cursor.y >= n); + defer self.assertIntegrity(); const page_pin = self.cursor.page_pin.up(n).?; const page_rac = page_pin.rowAndCell(); @@ -387,6 +429,7 @@ pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { /// 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); + defer self.assertIntegrity(); // 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. @@ -403,6 +446,7 @@ pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { /// Move the cursor to some absolute horizontal position. pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { assert(x < self.pages.cols); + defer self.assertIntegrity(); self.cursor.page_pin.x = x; const page_rac = self.cursor.page_pin.rowAndCell(); @@ -414,6 +458,7 @@ pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void { assert(x < self.pages.cols); assert(y < self.pages.rows); + defer self.assertIntegrity(); var page_pin = if (y < self.cursor.y) self.cursor.page_pin.up(self.cursor.y - y).? @@ -434,6 +479,8 @@ pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) /// 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 { + defer self.assertIntegrity(); + // 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 @@ -481,6 +528,7 @@ pub fn cursorReload(self: *Screen) void { /// 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); + defer self.assertIntegrity(); // If we have no scrollback, then we shift all our rows instead. if (self.no_scrollback) { @@ -585,6 +633,13 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void { self.cursor = other; errdefer self.cursor = old; + // Clear our style information initially so runtime safety integrity + // checks pass since there is a period below where the cursor is + // invalid. + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + // We need to keep our old x/y because that is our cursorAbsolute // will fix up our pointers. // @@ -596,6 +651,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor) !void { self.cursorAbsolute(other.x, other.y); // We keep the old style ref so manualStyleUpdate can clean our old style up. + self.cursor.style = other.style; self.cursor.style_id = old.style_id; self.cursor.style_ref = old.style_ref; try self.manualStyleUpdate(); @@ -1089,6 +1145,11 @@ pub fn manualStyleUpdate(self: *Screen) !void { return; } + // From here on out, we may need to reload the cursor if we adjust + // the pages to account for space errors below... flag this to true + // in that case. + var cursor_reload = false; + // 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 @@ -1121,7 +1182,7 @@ pub fn manualStyleUpdate(self: *Screen) !void { } // Since this modifies our cursor page, we need to reload - self.cursorReload(); + cursor_reload = true; break :md try page.styles.upsert( page.memory, @@ -1130,6 +1191,7 @@ pub fn manualStyleUpdate(self: *Screen) !void { }; self.cursor.style_id = md.id; self.cursor.style_ref = &md.ref; + if (cursor_reload) self.cursorReload(); } /// Append a grapheme to the given cell within the current cursor row. diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 0d73801852..92230538d4 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -51,6 +51,15 @@ pub const Style = struct { return std.mem.eql(u8, std.mem.asBytes(&self), def); } + /// True if the style is equal to another style. + pub fn eql(self: Style, other: Style) bool { + return std.mem.eql( + u8, + std.mem.asBytes(&self), + std.mem.asBytes(&other), + ); + } + /// Returns the bg color for a cell with this style given the cell /// that has this style and the palette to use. /// From 3b6ae6807cc78e1a487f03ddbfa23355be32993a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 20:45:57 -0700 Subject: [PATCH 374/428] terminal: add more integrity assertions --- src/terminal/Screen.zig | 15 ++++++++ src/terminal/Terminal.zig | 15 ++++++++ src/terminal/page.zig | 76 +++++++++++++++------------------------ 3 files changed, 59 insertions(+), 47 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0434a66aa5..adac2951e6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -709,6 +709,8 @@ pub const Scroll = union(enum) { /// Scroll the viewport of the terminal grid. pub fn scroll(self: *Screen, behavior: Scroll) void { + defer self.assertIntegrity(); + // 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. @@ -726,6 +728,8 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { /// See PageList.scrollClear. In addition to that, we reset the cursor /// to be on top. pub fn scrollClear(self: *Screen) !void { + defer self.assertIntegrity(); + try self.pages.scrollClear(); self.cursorReload(); @@ -748,6 +752,8 @@ pub fn eraseRows( tl: point.Point, bl: ?point.Point, ) void { + defer self.assertIntegrity(); + // Erase the rows self.pages.eraseRows(tl, bl); @@ -769,6 +775,8 @@ pub fn clearRows( bl: ?point.Point, protected: bool, ) void { + defer self.assertIntegrity(); + var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { @@ -842,6 +850,8 @@ pub fn clearCells( } @memset(cells, self.blankCell()); + page.assertIntegrity(); + self.assertIntegrity(); } /// Clear cells but only if they are not protected. @@ -856,6 +866,9 @@ pub fn clearUnprotectedCells( const cell_multi: [*]Cell = @ptrCast(cell); self.clearCells(page, row, cell_multi[0..1]); } + + page.assertIntegrity(); + self.assertIntegrity(); } /// Clears the prompt lines if the cursor is currently at a prompt. This @@ -977,6 +990,7 @@ fn resizeInternal( // If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload(); + self.assertIntegrity(); } /// Set a style attribute for the current cursor. @@ -1192,6 +1206,7 @@ pub fn manualStyleUpdate(self: *Screen) !void { self.cursor.style_id = md.id; self.cursor.style_ref = &md.ref; if (cursor_reload) self.cursorReload(); + self.assertIntegrity(); } /// Append a grapheme to the given cell within the current cursor row. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c89759be32..c8cad37802 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -223,6 +223,10 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're not on the main display, do nothing for now if (self.status_display != .main) return; + // After doing any printing, wrapping, scrolling, etc. we want to ensure + // that our screen remains in a consistent state. + defer self.screen.assertIntegrity(); + // Our right margin depends where our cursor is now. const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) self.cols @@ -470,6 +474,8 @@ fn printCell( unmapped_c: u21, wide: Cell.Wide, ) void { + defer self.screen.assertIntegrity(); + // TODO: spacers should use a bgcolor only cell const c: u21 = c: { @@ -627,6 +633,9 @@ fn printWrap(self: *Terminal) !void { // 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; + + // Assure that our screen is consistent + self.screen.assertIntegrity(); } /// Set the charset into the given slot. @@ -876,6 +885,9 @@ pub fn restoreCursor(self: *Terminal) !void { @min(saved.x, self.cols - 1), @min(saved.y, self.rows - 1), ); + + // Ensure our screen is consistent + self.screen.assertIntegrity(); } /// Set the character protection mode for the terminal. @@ -1315,6 +1327,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { const dst_row = dst.*; dst.* = src.*; src.* = dst_row; + + // Ensure what we did didn't corrupt the page + p.page.data.assertIntegrity(); continue; } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 2b36ed92ed..3438f7ec3d 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -176,7 +176,6 @@ pub const Page = struct { pub const IntegrityError = error{ UnmarkedGraphemeRow, - MarkedGraphemeRow, MissingGraphemeData, InvalidGraphemeCount, MissingStyle, @@ -267,16 +266,6 @@ pub const Page = struct { ); return IntegrityError.UnmarkedGraphemeRow; } - } else { - // If no cells in a row have grapheme data, the row must - // not be marked as having grapheme data. - if (row.grapheme) { - log.warn( - "page integrity violation y={} row marked but no grapheme data", - .{y}, - ); - return IntegrityError.MarkedGraphemeRow; - } } } @@ -406,6 +395,9 @@ pub const Page = struct { dst_row, src_row, ); + + // We should remain consistent + self.assertIntegrity(); } /// Clone a single row from another page into this page. @@ -454,6 +446,9 @@ pub const Page = struct { dst_cell.style_id = md.id; } } + + // The final page should remain consistent + self.assertIntegrity(); } /// Get a single row. y must be valid. @@ -501,6 +496,8 @@ pub const Page = struct { dst_left: usize, len: usize, ) void { + defer self.assertIntegrity(); + const src_cells = src_row.cells.ptr(self.memory)[src_left .. src_left + len]; const dst_cells = dst_row.cells.ptr(self.memory)[dst_left .. dst_left + len]; @@ -527,6 +524,9 @@ pub const Page = struct { dst.content_tag = .codepoint; self.moveGrapheme(src, dst); } + + // The destination row must be marked + dst_row.grapheme = true; } // Clear our source row now that the copy is complete @@ -544,6 +544,8 @@ pub const Page = struct { left: usize, end: usize, ) void { + defer self.assertIntegrity(); + const cells = row.cells.ptr(self.memory)[left..end]; if (row.grapheme) { for (cells) |*cell| { @@ -572,6 +574,8 @@ pub const Page = struct { /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { + defer self.assertIntegrity(); + if (comptime std.debug.runtime_safety) assert(cell.hasText()); const cell_offset = getOffset(Cell, self.memory, cell); @@ -640,7 +644,7 @@ pub const Page = struct { /// Move the graphemes from one cell to another. This can't fail /// because we avoid any allocations since we're just moving data. - pub fn moveGrapheme(self: *const Page, src: *Cell, dst: *Cell) void { + pub fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { if (comptime std.debug.runtime_safety) { assert(src.hasGrapheme()); assert(!dst.hasGrapheme()); @@ -654,10 +658,12 @@ pub const Page = struct { map.removeByPtr(entry.key_ptr); map.putAssumeCapacity(dst_offset, value); src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; } /// Clear the graphemes for a given cell. pub fn clearGrapheme(self: *Page, row: *Row, cell: *Cell) void { + defer self.assertIntegrity(); if (comptime std.debug.runtime_safety) assert(cell.hasGrapheme()); // Get our entry in the map, which must exist @@ -692,6 +698,8 @@ pub const Page = struct { // the places we call this is from insertBlanks where the cells have // already swapped cell data but not grapheme data. + defer self.assertIntegrity(); + // Get our entry in the map, which must exist const src_offset = getOffset(Cell, self.memory, src); var map = self.grapheme_map.map(self.memory); @@ -1575,7 +1583,7 @@ test "Page moveCells graphemes" { defer page.deinit(); // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.cell.* = .{ .content_tag = .codepoint, @@ -1587,11 +1595,11 @@ test "Page moveCells graphemes" { const src = page.getRow(0); const dst = page.getRow(1); - page.moveCells(src, 0, dst, 0, page.capacity.cols); + page.moveCells(src, 0, dst, 0, page.size.cols); try testing.expectEqual(original_count, page.graphemeCount()); // New rows should have text - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 1); try testing.expectEqual( @as(u21, @intCast(x + 1)), @@ -1605,7 +1613,7 @@ test "Page moveCells graphemes" { } // Old row should be blank - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); try testing.expectEqual( @as(u21, 0), @@ -1623,7 +1631,7 @@ test "Page verifyIntegrity graphemes good" { defer page.deinit(); // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.cell.* = .{ .content_tag = .codepoint, @@ -1644,7 +1652,7 @@ test "Page verifyIntegrity grapheme row not marked" { defer page.deinit(); // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.cell.* = .{ .content_tag = .codepoint, @@ -1662,32 +1670,6 @@ test "Page verifyIntegrity grapheme row not marked" { ); } -test "Page verifyIntegrity text row marked as grapheme" { - var page = try Page.init(.{ - .cols = 10, - .rows = 10, - .styles = 8, - }); - defer page.deinit(); - - // Write - for (0..page.capacity.cols) |x| { - const rac = page.getRowAndCell(x, 0); - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x + 1) }, - }; - } - - // Make invalid by unmarking the row - page.getRow(0).grapheme = true; - - try testing.expectError( - Page.IntegrityError.MarkedGraphemeRow, - page.verifyIntegrity(testing.allocator), - ); -} - test "Page verifyIntegrity styles good" { var page = try Page.init(.{ .cols = 10, @@ -1702,7 +1684,7 @@ test "Page verifyIntegrity styles good" { } }); // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.row.styled = true; rac.cell.* = .{ @@ -1730,7 +1712,7 @@ test "Page verifyIntegrity styles ref count mismatch" { } }); // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.row.styled = true; rac.cell.* = .{ @@ -1769,7 +1751,7 @@ test "Page verifyIntegrity styles extra" { md2.ref += 1; // Write - for (0..page.capacity.cols) |x| { + for (0..page.size.cols) |x| { const rac = page.getRowAndCell(x, 0); rac.row.styled = true; rac.cell.* = .{ From 4c35f359049f3a3cf5d6c2119c12b4e17c150032 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 21:01:30 -0700 Subject: [PATCH 375/428] terminal: get rid of some verifications, comment why --- src/terminal/page.zig | 60 +++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3438f7ec3d..01b04b1fe0 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -200,6 +200,15 @@ pub const Page = struct { /// /// Integrity errors are also logged as warnings. pub fn verifyIntegrity(self: *Page, alloc_gpa: Allocator) !void { + // Some things that seem like we should check but do not: + // + // - We do not check that the style ref count is exact, only that + // it is at least what we see. We do this because some fast paths + // trim rows without clearing data. + // - We do not check that styles seen is exactly the same as the + // styles count in the page for the same reason as above. + // + var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); const alloc = arena.allocator(); @@ -278,47 +287,13 @@ pub const Page = struct { return IntegrityError.InvalidGraphemeCount; } - // There is allowed to be exactly one zero ref count style for - // the active style. If we see this, we should add it to our seen - // styles so the math is correct. - { - const id_map = self.styles.id_map.map(self.memory); - var it = id_map.iterator(); - while (it.next()) |entry| { - const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; - const md = self.styles.upsert(self.memory, style_val) catch unreachable; - if (md.ref == 0) { - const gop = try styles_seen.getOrPut(entry.key_ptr.*); - if (gop.found_existing) { - log.warn( - "page integrity violation zero ref style seen multiple times id={}", - .{entry.key_ptr.*}, - ); - return IntegrityError.MismatchedStyleRef; - } - - gop.value_ptr.* = 0; - break; - } - } - } - - // Our unique styles seen should exactly match the style count. - if (styles_seen.count() != self.styles.count(self.memory)) { - log.warn( - "page integrity violation style count mismatch expected={} actual={}", - .{ styles_seen.count(), self.styles.count(self.memory) }, - ); - return IntegrityError.InvalidStyleCount; - } - // Verify all our styles have the correct ref count. { var it = styles_seen.iterator(); while (it.next()) |entry| { const style_val = self.styles.lookupId(self.memory, entry.key_ptr.*).?.*; const md = self.styles.upsert(self.memory, style_val) catch unreachable; - if (md.ref != entry.value_ptr.*) { + if (md.ref < entry.value_ptr.*) { log.warn( "page integrity violation style ref count mismatch id={} expected={} actual={}", .{ entry.key_ptr.*, entry.value_ptr.*, md.ref }, @@ -529,8 +504,19 @@ pub const Page = struct { dst_row.grapheme = true; } - // Clear our source row now that the copy is complete - self.clearCells(src_row, src_left, src_left + len); + // The destination row has styles if any of the cells are styled + if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { + if (c.style_id != style.default_id) break :styled true; + } else false; + + // Clear our source row now that the copy is complete. We can NOT + // use clearCells here because clearCells will garbage collect our + // styles and graphames but we moved them above. + @memset(src_cells, .{}); + if (src_cells.len == self.size.cols) { + src_row.grapheme = false; + src_row.styled = false; + } } /// Clear the cells in the given row. This will reclaim memory used From 40cac97c863ed12565c941aa33b17dcc39ac5d8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 21:26:29 -0700 Subject: [PATCH 376/428] terminal: insertChars/deleteChars needs to account properly --- src/terminal/Terminal.zig | 40 +-------------- src/terminal/page.zig | 105 ++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 99 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c8cad37802..fc441e89ee 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1544,25 +1544,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { 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); - } + page.swapCells(src, dst); } } @@ -1636,25 +1618,7 @@ pub fn deleteChars(self: *Terminal, count: usize) void { 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); - } + page.swapCells(src, dst); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 01b04b1fe0..a6245afcc6 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -498,6 +498,8 @@ pub const Page = struct { // Required for moveGrapheme assertions dst.content_tag = .codepoint; self.moveGrapheme(src, dst); + src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; } // The destination row must be marked @@ -519,6 +521,43 @@ pub const Page = struct { } } + /// Swap two cells within the same row as quickly as possible. + pub fn swapCells( + self: *Page, + src: *Cell, + dst: *Cell, + ) void { + defer self.assertIntegrity(); + + // Graphemes are keyed by cell offset so we do have to move them. + // We do this first so that all our grapheme state is correct. + if (src.hasGrapheme() or dst.hasGrapheme()) { + if (src.hasGrapheme() and !dst.hasGrapheme()) { + self.moveGrapheme(src, dst); + } else if (!src.hasGrapheme() and dst.hasGrapheme()) { + self.moveGrapheme(dst, src); + } else { + // Both had graphemes, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + + // Copy the metadata. Note that we do NOT have to worry about + // styles because styles are keyed by ID and we're preserving the + // exact ref count and row state here. + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + /// Clear the cells in the given row. This will reclaim memory used /// by graphemes and styles. Note that if the style cleared is still /// active, Page cannot know this and it will still be ref counted down. @@ -630,7 +669,11 @@ pub const Page = struct { /// Move the graphemes from one cell to another. This can't fail /// because we avoid any allocations since we're just moving data. - pub fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + /// + /// WARNING: This will NOT change the content_tag on the cells because + /// there are scenarios where we want to move graphemes without changing + /// the content tag. Callers beware but assertIntegrity should catch this. + fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { if (comptime std.debug.runtime_safety) { assert(src.hasGrapheme()); assert(!dst.hasGrapheme()); @@ -643,8 +686,6 @@ pub const Page = struct { const value = entry.value_ptr.*; map.removeByPtr(entry.key_ptr); map.putAssumeCapacity(dst_offset, value); - src.content_tag = .codepoint; - dst.content_tag = .codepoint_grapheme; } /// Clear the graphemes for a given cell. @@ -678,28 +719,6 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).count(); } - /// Move graphemes to another cell in the same row. - pub fn moveGraphemeWithinRow(self: *Page, src: *Cell, dst: *Cell) void { - // Note: we don't assert src has graphemes here because one of - // the places we call this is from insertBlanks where the cells have - // already swapped cell data but not grapheme data. - - defer self.assertIntegrity(); - - // Get our entry in the map, which must exist - const src_offset = getOffset(Cell, self.memory, src); - var map = self.grapheme_map.map(self.memory); - const entry = map.getEntry(src_offset).?; - const value = entry.value_ptr.*; - - // Remove the entry so we know we have space - map.removeByPtr(entry.key_ptr); - - // Add the entry for the new cell - const dst_offset = getOffset(Cell, self.memory, dst); - map.putAssumeCapacity(dst_offset, value); - } - pub const Layout = struct { total_size: usize, rows_start: usize, @@ -1717,39 +1736,3 @@ test "Page verifyIntegrity styles ref count mismatch" { page.verifyIntegrity(testing.allocator), ); } - -test "Page verifyIntegrity styles extra" { - var page = try Page.init(.{ - .cols = 10, - .rows = 10, - .styles = 8, - }); - defer page.deinit(); - - // Upsert a style we'll use - const md = try page.styles.upsert(page.memory, .{ .flags = .{ - .bold = true, - } }); - - const md2 = try page.styles.upsert(page.memory, .{ .flags = .{ - .italic = true, - } }); - md2.ref += 1; - - // Write - for (0..page.size.cols) |x| { - const rac = page.getRowAndCell(x, 0); - rac.row.styled = true; - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x + 1) }, - .style_id = md.id, - }; - md.ref += 1; - } - - try testing.expectError( - Page.IntegrityError.InvalidStyleCount, - page.verifyIntegrity(testing.allocator), - ); -} From 65696c99009a774a5c3513e6533a404fe57875e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 21:30:52 -0700 Subject: [PATCH 377/428] terminal: clearcells only decs cursor ref if same page --- src/terminal/Screen.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index adac2951e6..bf16e25c81 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -821,7 +821,9 @@ pub fn clearCells( // 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) { + if (page == &self.cursor.page_pin.page.data and + cell.style_id == self.cursor.style_id) + { self.cursor.style_ref.?.* -= 1; continue; } From 71c04db5a9ddde29c3e00fcc6858c6f9eaec78f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 21:43:06 -0700 Subject: [PATCH 378/428] terminal: fix cursor style on deleteLines --- src/terminal/Terminal.zig | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index fc441e89ee..2efeaa700c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1345,6 +1345,15 @@ pub fn insertLines(self: *Terminal, count: usize) void { (self.scrolling_region.right - self.scrolling_region.left) + 1, ); } + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; } // Inserted lines should keep our bg color @@ -1446,6 +1455,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const dst_row = dst.*; dst.* = src.*; src.* = dst_row; + + // Ensure what we did didn't corrupt the page + p.page.data.assertIntegrity(); continue; } @@ -1461,6 +1473,15 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { (self.scrolling_region.right - self.scrolling_region.left) + 1, ); } + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; } const clear_top = top.down(scroll_amount).?; From f848ed2a635991552dad5cdf48136aa251220ad9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 21:52:14 -0700 Subject: [PATCH 379/428] terminal: handle row wrap integrity issues on reflow --- src/terminal/PageList.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 0531fda580..b3e07ac0c2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1162,9 +1162,9 @@ fn reflowPage( // If we're still in a wrapped line at the end of our page, // we traverse forward and continue reflowing until we complete // this entire line. - if (src_cursor.page_row.wrap) { + if (src_cursor.page_row.wrap) wrap: { src_completing_wrap = true; - src_node = src_node.next.?; + src_node = src_node.next orelse break :wrap; src_cursor = ReflowCursor.init(&src_node.data); continue :src_loop; } From 0bc831d19f3004e871c66d283ecc4318d854f783 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 21 Mar 2024 22:00:07 -0700 Subject: [PATCH 380/428] terminal: relax grapheme integrity check for fast paths --- src/terminal/main.zig | 2 +- src/terminal/page.zig | 4 +++- src/terminal/style.zig | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/terminal/main.zig b/src/terminal/main.zig index cf05771ec4..be60aa477c 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -56,7 +56,7 @@ pub const Attribute = sgr.Attribute; test { @import("std").testing.refAllDecls(@This()); - // todo: make top-level imports + // Internals _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); _ = @import("size.zig"); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index a6245afcc6..e26f82388b 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -207,6 +207,8 @@ pub const Page = struct { // trim rows without clearing data. // - We do not check that styles seen is exactly the same as the // styles count in the page for the same reason as above. + // - We only check that we saw less graphemes than the total memory + // used for the same reason as styles above. // var arena = ArenaAllocator.init(alloc_gpa); @@ -279,7 +281,7 @@ pub const Page = struct { } // Our graphemes seen should exactly match the grapheme count - if (graphemes_seen != self.graphemeCount()) { + if (graphemes_seen > self.graphemeCount()) { log.warn( "page integrity violation grapheme count mismatch expected={} actual={}", .{ graphemes_seen, self.graphemeCount() }, diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 92230538d4..3f1c0208aa 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -232,7 +232,6 @@ pub const Set = struct { /// No more available IDs. Perform a garbage collection /// operation to compact ID space. - /// TODO: implement gc operation Overflow, }; From ee5be265117db90d6e4d883164ac64bf4db66d40 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 10:40:34 -0700 Subject: [PATCH 381/428] terminal: prevent false positive integrity check --- src/terminal/page.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index e26f82388b..04e183900b 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -412,6 +412,9 @@ pub const Page = struct { for (cells, other_cells) |*dst_cell, *src_cell| { dst_cell.* = src_cell.*; if (src_cell.hasGrapheme()) { + // To prevent integrity checks flipping + if (comptime std.debug.runtime_safety) dst_cell.style_id = style.default_id; + dst_cell.content_tag = .codepoint; // required for appendGrapheme const cps = other.lookupGrapheme(src_cell).?; for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); From cd305348080ce5465b69f93a8c3f256cf3069d86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 11:55:55 -0700 Subject: [PATCH 382/428] terminal: no scrollback eraseRows needs to fix style --- src/terminal/Screen.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index bf16e25c81..e31b286830 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -539,6 +539,20 @@ pub fn cursorDownScroll(self: *Screen) !void { // Erase rows will shift our rows up self.pages.eraseRows(.{ .active = .{} }, .{ .active = .{} }); + // The above may clear our cursor so we need to update that + // again. If this fails (highly unlikely) we just reset + // the cursor. + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate + // handles page splitting, overflow, and more. This should only + // happen if we're out of RAM. In this case, we'll just degrade + // gracefully back to the default style. + log.err("failed to update style on cursor scroll err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + self.cursor.style_ref = null; + }; + // 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).?; From 8818e4da05d24f96e04262a3cfb5774d5fc883a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 12:07:00 -0700 Subject: [PATCH 383/428] terminal: bitmapallocator handles perfectly divisble chunk size --- src/terminal/bitmap_allocator.zig | 45 ++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 7b3a198be4..550a504168 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -203,22 +203,30 @@ fn findFreeChunks(bitmaps: []u64, n: usize) ?usize { // then add one and continue, we're still accumulating blanks. if (seq != div) { seq += 1; - continue; + if (seq != div or mod > 0) continue; } // We've reached the seq count see if this has mod starting empty // blanks. - const final = @as(u64, std.math.maxInt(u64)) >> @intCast(64 - mod); - if (bitmap.* & final == 0) { - // No blanks, reset. - seq = 0; - continue; + if (mod > 0) { + const final = @as(u64, std.math.maxInt(u64)) >> @intCast(64 - mod); + if (bitmap.* & final == 0) { + // No blanks, reset. + seq = 0; + continue; + } + + bitmap.* ^= final; } // Found! Set all in our sequence to full and mask our final. - const start_idx = idx - seq; - for (start_idx..idx) |i| bitmaps[i] = 0; - bitmap.* ^= final; + // The "zero_mod" modifier below handles the case where we have + // a perfectly divisible number of chunks so we don't have to + // mark the trailing bitmap. + const zero_mod = @intFromBool(mod == 0); + const start_idx = idx - (seq - zero_mod); + const end_idx = idx + zero_mod; + for (start_idx..end_idx) |i| bitmaps[i] = 0; return (start_idx * 64); } @@ -344,6 +352,25 @@ test "findFreeChunks larger than 64 chunks not at beginning" { try testing.expectEqual(@as(usize, 64), idx); } +test "findFreeChunks larger than 64 chunks exact" { + const testing = std.testing; + + var bitmaps = [_]u64{ + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + 0b11111111_11111111_11111111_11111111_11111111_11111111_11111111_11111111, + }; + const idx = findFreeChunks(&bitmaps, 128).?; + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[0], + ); + try testing.expectEqual( + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + bitmaps[1], + ); + try testing.expectEqual(@as(usize, 0), idx); +} + test "BitmapAllocator layout" { const Alloc = BitmapAllocator(4); const cap = 64 * 4; From 06a8e4ae72b6b6b1975b7fe67984741e3b3e5560 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 19:45:59 -0700 Subject: [PATCH 384/428] terminal: spacer heads should only exist w/o l/r margin --- src/terminal/Terminal.zig | 92 ++++++++++++++++++++++++++++++++++++--- src/terminal/stream.zig | 6 +-- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2efeaa700c..8dcf70620f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -218,7 +218,7 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + 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; @@ -316,7 +316,10 @@ pub fn print(self: *Terminal, c: u21) !void { // char as normal. if (self.screen.cursor.x == right_limit - 1) { if (!self.modes.get(.wraparound)) return; - self.printCell(' ', .spacer_head); + self.printCell( + ' ', + if (right_limit == self.cols) .spacer_head else .narrow, + ); try self.printWrap(); } @@ -442,7 +445,10 @@ pub fn print(self: *Terminal, c: u21) !void { // how xterm behaves. if (!self.modes.get(.wraparound)) return; - self.printCell(' ', .spacer_head); + // We only create a spacer head if we're at the real edge + // of the screen. Otherwise, we clear the space with a narrow. + // This allows soft wrapping to work correctly. + self.printCell(' ', if (right_limit == self.cols) .spacer_head else .narrow); try self.printWrap(); } @@ -619,7 +625,11 @@ fn printCell( } fn printWrap(self: *Terminal) !void { - self.screen.cursor.page_row.wrap = true; + // We only mark that we soft-wrapped if we're at the edge of our + // full screen. We don't mark the row as wrapped if we're in the + // middle due to a right margin. + const mark_wrap = self.screen.cursor.x == self.cols - 1; + if (mark_wrap) 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 @@ -630,9 +640,11 @@ fn printWrap(self: *Terminal) !void { 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; + if (mark_wrap) { + // 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; + } // Assure that our screen is consistent self.screen.assertIntegrity(); @@ -2426,6 +2438,33 @@ test "Terminal: print wide char" { } } +test "Terminal: print wide char at edge creates spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.setCursorPos(1, 10); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + 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 = 1 } }).?; + 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, .{ .cols = 1, .rows = 2 }); @@ -3232,6 +3271,12 @@ test "Terminal: print right margin wrap" { defer testing.allocator.free(str); try testing.expectEqualStrings("1234X6789\n Y", str); } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } } test "Terminal: print right margin outside" { @@ -3268,6 +3313,39 @@ test "Terminal: print right margin outside wrap" { } } +test "Terminal: print wide char at right margin does not create spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + 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); + + const row = list_cell.row; + try testing.expect(!row.wrap); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + 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 = 3, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d32149cc15..286f8ad982 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -262,9 +262,9 @@ pub fn Stream(comptime Handler: type) type { for (actions) |action_opt| { const action = action_opt orelse continue; - // if (action != .print) { - // log.info("action: {}", .{action}); - // } + if (action != .print) { + log.info("action: {}", .{action}); + } // If this handler handles everything manually then we do nothing // if it can be processed. From 9685a569416d88fa016ac54e6ebd79207b404940 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 19:57:07 -0700 Subject: [PATCH 385/428] terminal: clear unprotected row should preserve row attrs --- src/terminal/Screen.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e31b286830..51e86d7825 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -801,13 +801,13 @@ pub fn clearRows( // Clear all cells if (protected) { self.clearUnprotectedCells(&chunk.page.data, row, cells); + // We need to preserve other row attributes since we only + // cleared unprotected cells. + row.cells = cells_offset; } else { self.clearCells(&chunk.page.data, row, cells); + row.* = .{ .cells = cells_offset }; } - - // Reset our row to point to the proper memory but everything - // else is zeroed. - row.* = .{ .cells = cells_offset }; } } } From a301f7da0626cec9ee27c5c6fb3afd2e6ce177c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 19:57:24 -0700 Subject: [PATCH 386/428] terminal: undo accidental debug logs --- src/terminal/stream.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 286f8ad982..d32149cc15 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -262,9 +262,9 @@ pub fn Stream(comptime Handler: type) type { for (actions) |action_opt| { const action = action_opt orelse continue; - if (action != .print) { - log.info("action: {}", .{action}); - } + // if (action != .print) { + // log.info("action: {}", .{action}); + // } // If this handler handles everything manually then we do nothing // if it can be processed. From 25a5e078fa2e89ffefb9733b35ad9d6942d0ac5a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 20:01:27 -0700 Subject: [PATCH 387/428] terminal: more accidental logging --- src/terminal/Terminal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8dcf70620f..2dc4faf92a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -218,7 +218,7 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } pub fn print(self: *Terminal, c: u21) !void { - log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + // 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; From eb6536f4a74eb89703bc636439fecffc6c2a6c44 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 20:29:45 -0700 Subject: [PATCH 388/428] address latest zig changes --- src/terminal-old/kitty/graphics_image.zig | 8 ++++---- src/terminal/page.zig | 15 ++++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/terminal-old/kitty/graphics_image.zig b/src/terminal-old/kitty/graphics_image.zig index d84ea91d61..249d8878f9 100644 --- a/src/terminal-old/kitty/graphics_image.zig +++ b/src/terminal-old/kitty/graphics_image.zig @@ -78,7 +78,7 @@ pub const LoadingImage = struct { if (comptime builtin.os.tag != .windows) { if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) { - // std.os.realpath *asserts* that the path does not have + // std.posix.realpath *asserts* that the path does not have // internal nulls instead of erroring. log.warn("failed to get absolute path: BadPathName", .{}); return error.InvalidData; @@ -86,7 +86,7 @@ pub const LoadingImage = struct { } var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = std.os.realpath(buf[0..size], &abs_buf) catch |err| { + const path = std.posix.realpath(buf[0..size], &abs_buf) catch |err| { log.warn("failed to get absolute path: {}", .{err}); return error.InvalidData; }; @@ -151,7 +151,7 @@ pub const LoadingImage = struct { if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; } defer if (medium == .temporary_file) { - std.os.unlink(path) catch |err| { + std.posix.unlink(path) catch |err| { log.warn("failed to delete temporary file: {}", .{err}); }; }; @@ -209,7 +209,7 @@ pub const LoadingImage = struct { // The temporary dir is sometimes a symlink. On macOS for // example /tmp is /private/var/... var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - if (std.os.realpath(dir, &buf)) |real_dir| { + if (std.posix.realpath(dir, &buf)) |real_dir| { if (std.mem.startsWith(u8, path, real_dir)) return true; } else |_| {} } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 04e183900b..3fee11a0ad 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const assert = std.debug.assert; const testing = std.testing; +const posix = std.posix; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const sgr = @import("sgr.zig"); @@ -113,15 +114,15 @@ pub const Page = struct { // anonymous mmap is guaranteed on Linux and macOS to be zeroed, // which is a critical property for us. assert(l.total_size % std.mem.page_size == 0); - const backing = try std.os.mmap( + const backing = try posix.mmap( null, l.total_size, - std.os.PROT.READ | std.os.PROT.WRITE, + posix.PROT.READ | posix.PROT.WRITE, .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0, ); - errdefer std.os.munmap(backing); + errdefer posix.munmap(backing); const buf = OffsetBuf.init(backing); return initBuf(buf, l); @@ -170,7 +171,7 @@ pub const Page = struct { /// this if you allocated the backing memory yourself (i.e. you used /// initBuf). pub fn deinit(self: *Page) void { - std.os.munmap(self.memory); + posix.munmap(self.memory); self.* = undefined; } @@ -310,15 +311,15 @@ pub const Page = struct { /// using the page allocator. If you want to manage memory manually, /// use cloneBuf. pub fn clone(self: *const Page) !Page { - const backing = try std.os.mmap( + const backing = try posix.mmap( null, self.memory.len, - std.os.PROT.READ | std.os.PROT.WRITE, + posix.PROT.READ | posix.PROT.WRITE, .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0, ); - errdefer std.os.munmap(backing); + errdefer posix.munmap(backing); return self.cloneBuf(backing); } From 8c148fc32e6d083145ae8cd79649f85cbcfbb074 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 22 Mar 2024 21:04:05 -0700 Subject: [PATCH 389/428] terminal: use std.meta.eql for equality checks --- src/terminal/style.zig | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 3f1c0208aa..4011b42984 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -47,17 +47,12 @@ pub const Style = struct { /// True if the style is the default style. pub fn default(self: Style) bool { - const def: []const u8 = comptime std.mem.asBytes(&Style{}); - return std.mem.eql(u8, std.mem.asBytes(&self), def); + return std.meta.eql(self, .{}); } /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - return std.mem.eql( - u8, - std.mem.asBytes(&self), - std.mem.asBytes(&other), - ); + return std.meta.eql(self, other); } /// Returns the bg color for a cell with this style given the cell From e4332891ee38f35c72c96f9fe912833da09283f4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Mar 2024 21:37:34 -0700 Subject: [PATCH 390/428] terminal: avoid memory fragmentation if possible on col grow --- src/terminal/PageList.zig | 185 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 9 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b3e07ac0c2..1e887fad58 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -165,16 +165,29 @@ fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { const cap = try std_capacity.adjust(.{ .cols = cols }); // Calculate the number of standard sized pages we need to represent - // an active area. We always need at least two pages so if we can fit - // all our rows in one cap then we say 2, otherwise we do the math. - const pages = if (cap.rows >= rows) 2 else try std.math.divCeil( + // an active area. + const pages_exact = if (cap.rows >= rows) 1 else try std.math.divCeil( usize, rows, cap.rows, ); + + // We always need at least one page extra so that we + // can fit partial pages to spread our active area across two pages. + // Even for caps that can't fit all rows in a single page, we add one + // because the most extra space we need at any given time is only + // the partial amount of one page. + const pages = pages_exact + 1; assert(pages >= 2); - return std_size * pages; + // log.debug("minMaxSize cols={} rows={} cap={} pages={}", .{ + // cols, + // rows, + // cap, + // pages, + // }); + + return PagePool.item_size * pages; } /// Initialize the page. The top of the first page in the list is always the @@ -1371,9 +1384,59 @@ fn resizeWithoutReflowGrowCols( } } + // Keeps track of all our copied rows. Assertions at the end is that + // we copied exactly our page size. + var copied: usize = 0; + + // This function has an unfortunate side effect in that it causes memory + // fragmentation on rows if the columns are increasing in a way that + // shrinks capacity rows. If we have pages that don't divide evenly then + // we end up creating a final page that is not using its full capacity. + // If this chunk isn't the last chunk in the page list, then we've created + // a page where we'll never reclaim that capacity. This makes our max size + // calculation incorrect since we'll throw away data even though we have + // excess capacity. To avoid this, we try to fill our previous page + // first if it has capacity. + // + // This can fail for many reasons (can't fit styles/graphemes, etc.) so + // if it fails then we give up and drop back into creating new pages. + if (prev) |prev_node| prev: { + const prev_page = &prev_node.data; + + // We only want scenarios where we have excess capacity. + if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; + + // We can copy as much as we can to fill the capacity or our + // current page size. + const len = @min( + prev_page.capacity.rows - prev_page.size.rows, + page.size.rows, + ); + + const src_rows = page.rows.ptr(page.memory)[0..len]; + const dst_rows = prev_page.rows.ptr(prev_page.memory)[prev_page.size.rows..]; + for (dst_rows, src_rows) |*dst_row, *src_row| { + prev_page.size.rows += 1; + copied += 1; + prev_page.cloneRowFrom( + page, + dst_row, + src_row, + ) catch { + // If an error happens, we undo our row copy and break out + // into creating a new page. + prev_page.size.rows -= 1; + copied -= 1; + break :prev; + }; + } + + assert(copied == len); + assert(prev_page.size.rows <= prev_page.capacity.rows); + } + // We need to loop because our col growth may force us // to split pages. - var copied: usize = 0; while (copied < page.size.rows) { const new_page = try self.createPage(cap); defer new_page.data.assertIntegrity(); @@ -1381,13 +1444,22 @@ fn resizeWithoutReflowGrowCols( // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. const len = @min(cap.rows, page.size.rows - copied); - new_page.data.size.rows = len; - // The range of rows we're copying from the old page. + // Perform the copy const y_start = copied; const y_end = copied + len; - try new_page.data.cloneFrom(page, y_start, y_end); - copied += len; + const src_rows = page.rows.ptr(page.memory)[y_start..y_end]; + const dst_rows = new_page.data.rows.ptr(new_page.data.memory)[0..len]; + for (dst_rows, src_rows) |*dst_row, *src_row| { + new_page.data.size.rows += 1; + errdefer new_page.data.size.rows -= 1; + try new_page.data.cloneRowFrom( + page, + dst_row, + src_row, + ); + } + copied = y_end; // Insert our new page self.pages.insertBefore(chunk.page, new_page); @@ -4688,6 +4760,101 @@ test "PageList resize (no reflow) more cols" { } } +// This test is a bit convoluted so I want to explain: what we are trying +// to verify here is that when we increase cols such that our rows per page +// shrinks, we don't fragment our rows across many pages because this ends +// up wasting a lot of memory. +// +// This is particularly important for alternate screen buffers where we +// don't have scrollback so our max size is very small. If we don't do this, +// we end up pruning our pages and that causes resizes to fail! +test "PageList resize (no reflow) more cols forces less rows per page" { + const testing = std.testing; + const alloc = testing.allocator; + + // This test requires initially that our rows fit into one page. + const cols: size.CellCountInt = 5; + const rows: size.CellCountInt = 150; + try testing.expect((try std_capacity.adjust(.{ .cols = cols })).rows >= rows); + var s = try init(alloc, cols, rows, 0); + defer s.deinit(); + + // Then we need to resize our cols so that our rows per page shrinks. + // This will force our resize to split our rows across two pages. + { + const new_cols = new_cols: { + var new_cols: size.CellCountInt = 50; + var cap = try std_capacity.adjust(.{ .cols = new_cols }); + while (cap.rows >= rows) { + new_cols += 50; + cap = try std_capacity.adjust(.{ .cols = new_cols }); + } + + break :new_cols new_cols; + }; + try s.resize(.{ .cols = new_cols, .reflow = false }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, rows), s.totalRows()); + } + + // Every page except the last should be full + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + if (page == s.pages.last.?) break; + try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + } + } + + // Now we need to resize again to a col size that further shrinks + // our last capacity. + { + const page = &s.pages.first.?.data; + try testing.expect(page.size.rows == page.capacity.rows); + const new_cols = new_cols: { + var new_cols = page.size.cols + 50; + var cap = try std_capacity.adjust(.{ .cols = new_cols }); + while (cap.rows >= page.size.rows) { + new_cols += 50; + cap = try std_capacity.adjust(.{ .cols = new_cols }); + } + + break :new_cols new_cols; + }; + + try s.resize(.{ .cols = new_cols, .reflow = false }); + try testing.expectEqual(@as(usize, new_cols), s.cols); + try testing.expectEqual(@as(usize, rows), s.totalRows()); + } + + // Every page except the last should be full + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + if (page == s.pages.last.?) break; + try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + } + } +} + +// This is a crash case we found with no scrollback scenarios. This is +// also covered in the test above where we verify growing cols doesn't +// fragment memory. +test "PageList resize (no reflow) to large cols and rows with no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + for (1..6) |i| { + const amount: size.CellCountInt = @intCast(500 * i); + try s.resize(.{ .cols = amount, .rows = amount, .reflow = false }); + try testing.expectEqual(@as(usize, amount), s.cols); + try testing.expectEqual(@as(usize, amount), s.totalRows()); + } +} + test "PageList resize (no reflow) less cols then more cols" { const testing = std.testing; const alloc = testing.allocator; From 6cbe6995337d9a5262f558a5ce5cf4bd79476852 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 Mar 2024 21:43:16 -0700 Subject: [PATCH 391/428] terminal: remove problematic test on 4k pages, still working on it --- src/terminal/PageList.zig | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 1e887fad58..976a16d8e3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4837,24 +4837,6 @@ test "PageList resize (no reflow) more cols forces less rows per page" { } } -// This is a crash case we found with no scrollback scenarios. This is -// also covered in the test above where we verify growing cols doesn't -// fragment memory. -test "PageList resize (no reflow) to large cols and rows with no scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - for (1..6) |i| { - const amount: size.CellCountInt = @intCast(500 * i); - try s.resize(.{ .cols = amount, .rows = amount, .reflow = false }); - try testing.expectEqual(@as(usize, amount), s.cols); - try testing.expectEqual(@as(usize, amount), s.totalRows()); - } -} - test "PageList resize (no reflow) less cols then more cols" { const testing = std.testing; const alloc = testing.allocator; From 225cc642b971ee563acbfd08d2ffac57f9587173 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 09:45:35 -0700 Subject: [PATCH 392/428] terminal: allow growing beyond max size for active area to fit --- src/terminal/PageList.zig | 62 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 976a16d8e3..dd1543202a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -158,6 +158,12 @@ pub const Viewport = union(enum) { /// such that we can fit the active area AND at least two pages. Note we /// need the two pages for algorithms to work properly (such as grow) but /// we don't need to fit double the active area. +/// +/// This min size may not be totally correct in the case that a large +/// number of other dimensions makes our row size in a page very small. +/// But this gives us a nice fast heuristic for determining min/max size. +/// Therefore, if the page size is violated you should always also verify +/// that we have enough space for the active area. fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { // Get our capacity to fit our rows. If the cols are too big, it may // force less rows than we want meaning we need more than one page to @@ -850,7 +856,6 @@ fn reflowPage( } self.erasePage(initial_node); - assert(self.page_size <= self.maxSize()); return; } @@ -1696,10 +1701,27 @@ pub fn scrollClear(self: *PageList) !void { /// Returns the actual max size. This may be greater than the explicit /// value if the explicit value is less than the min_max_size. +/// +/// This value is a HEURISTIC. You cannot assert on this value. We may +/// exceed this value if required to fit the active area. This may be +/// required in some cases if the active area has a large number of +/// graphemes, styles, etc. pub fn maxSize(self: *const PageList) usize { return @max(self.explicit_max_size, self.min_max_size); } +/// Returns true if we need to grow into our active area. +fn growRequiredForActive(self: *const PageList) bool { + var rows: usize = 0; + var page = self.pages.last; + while (page) |p| : (page = p.prev) { + rows += p.data.size.rows; + if (rows >= self.rows) return false; + } + + return true; +} + /// Grow the active area by exactly one row. /// /// This may allocate, but also may not if our current page has more @@ -1721,7 +1743,11 @@ pub fn grow(self: *PageList) !?*List.Node { // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first // page. - if (self.page_size + PagePool.item_size > self.maxSize()) { + if (self.page_size + PagePool.item_size > self.maxSize()) prune: { + // If we need to add more memory to ensure our active area is + // satisfied then we do not prune. + if (self.growRequiredForActive()) break :prune; + const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); // Get our first page and reset it to prepare for reuse. @@ -1762,7 +1788,6 @@ pub fn grow(self: *PageList) !?*List.Node { // We should never be more than our max size here because we've // verified the case above. - assert(self.page_size <= self.maxSize()); next_page.data.assertIntegrity(); return next_page; @@ -3159,6 +3184,37 @@ test "PageList active after grow" { } } +test "PageList grow allows exceeding max size for active area" { + const testing = std.testing; + const alloc = testing.allocator; + + // Setup our initial page so that we fully take up one page. + const cap = try std_capacity.adjust(.{ .cols = 5 }); + var s = try init(alloc, 5, cap.rows, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Grow once because we guarantee at least two pages of + // capacity so we want to get to that. + _ = try s.grow(); + const start_pages = s.totalPages(); + try testing.expect(start_pages >= 2); + + // Surgically modify our pages so that they have a smaller size. + { + var it = s.pages.first; + while (it) |page| : (it = page.next) { + page.data.size.rows = 1; + page.data.capacity.rows = 1; + } + } + + // Grow our row and ensure we don't prune pages because we need + // enough for the active area. + _ = try s.grow(); + try testing.expectEqual(start_pages + 1, s.totalPages()); +} + test "PageList scroll top" { const testing = std.testing; const alloc = testing.allocator; From f719999950955a2fbcaaf80734c1a85696934d94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 14:46:43 -0700 Subject: [PATCH 393/428] terminal: add assertion to page integrity that row/col count > 0 --- src/terminal/PageList.zig | 12 +++++++---- src/terminal/page.zig | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index dd1543202a..def46ee019 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1976,13 +1976,17 @@ pub fn eraseRows( while (it.next()) |chunk| { // If the chunk is a full page, deinit thit page and remove it from // the linked list. - if (chunk.fullPage()) full_page: { + if (chunk.fullPage()) { // A rare special case is that we're deleting everything // in our linked list. erasePage requires at least one other - // page so to handle this we break out of this handling and - // do a normal row by row erase. + // page so to handle this we reinit this page, set it to zero + // size which will let us grow our active area back. if (chunk.page.next == null and chunk.page.prev == null) { - break :full_page; + const page = &chunk.page.data; + erased += page.size.rows; + page.reinit(); + page.size.rows = 0; + break; } self.erasePage(chunk.page); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3fee11a0ad..ea0192b334 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -175,7 +175,15 @@ pub const Page = struct { self.* = undefined; } + /// Reinitialize the page with the same capacity. + pub fn reinit(self: *Page) void { + @memset(self.memory, 0); + self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); + } + pub const IntegrityError = error{ + ZeroRowCount, + ZeroColCount, UnmarkedGraphemeRow, MissingGraphemeData, InvalidGraphemeCount, @@ -212,6 +220,15 @@ pub const Page = struct { // used for the same reason as styles above. // + if (self.size.rows == 0) { + log.warn("page integrity violation zero row count", .{}); + return IntegrityError.ZeroRowCount; + } + if (self.size.cols == 0) { + log.warn("page integrity violation zero col count", .{}); + return IntegrityError.ZeroColCount; + } + var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); const alloc = arena.allocator(); @@ -1742,3 +1759,31 @@ test "Page verifyIntegrity styles ref count mismatch" { page.verifyIntegrity(testing.allocator), ); } + +test "Page verifyIntegrity zero rows" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.rows = 0; + try testing.expectError( + Page.IntegrityError.ZeroRowCount, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity zero cols" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.cols = 0; + try testing.expectError( + Page.IntegrityError.ZeroColCount, + page.verifyIntegrity(testing.allocator), + ); +} From be3749f1ada322f48765f520534d9b04694a6c94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 15:13:13 -0700 Subject: [PATCH 394/428] terminal: decaln accounts for styles across pages --- src/terminal/Terminal.zig | 58 +++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2dc4faf92a..72b03c12f4 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1950,26 +1950,31 @@ pub fn decaln(self: *Terminal) !void { // 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; - } + // Fill with Es by moving the cursor but reset it after. + while (true) { + const page = &self.screen.cursor.page_pin.page.data; + const row = self.screen.cursor.page_row; + const cells_multi: [*]Cell = row.cells.ptr(page.memory); + const cells = cells_multi[0..page.size.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; } + + if (self.screen.cursor.y == self.rows - 1) break; + self.screen.cursorDown(1); } + + // Reset the cursor to the top-left + self.setCursorPos(1, 1); } /// Execute a kitty graphics command. The buf is used to populate with @@ -7886,6 +7891,23 @@ test "Terminal: eraseDisplay protected above" { } } +test "Terminal: eraseDisplay complete preserves cursor" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Set our cursur + try t.setAttribute(.{ .bold = {} }); + try t.printString("AAAA"); + try testing.expect(t.screen.cursor.style_id != style.default_id); + + // Erasing the display may detect that our style is no longer in use + // and prune our style, which we don't want because its still our + // active cursor. + t.eraseDisplay(.complete, false); + try testing.expect(t.screen.cursor.style_id != style.default_id); +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 2 }); From 3d6ae29dc377efb10ceb0251668f9f69c98376df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 15:22:01 -0700 Subject: [PATCH 395/428] terminal: when reflowing, set style to default to prevent integrity fail --- src/terminal/PageList.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index def46ee019..77e0a78079 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1112,6 +1112,12 @@ fn reflowPage( dst_cursor.page_cell.* = src_cursor.page_cell.*; dst_cursor.page_cell.content_tag = .codepoint; + // Unset the style ID so our integrity checks don't fire. + // We handle style fixups after this switch block. + if (comptime std.debug.runtime_safety) { + dst_cursor.page_cell.style_id = stylepkg.default_id; + } + // Copy the graphemes const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?; for (src_cps) |cp| { From 1b8dc0c0c1f0aa87c6ff554dc4084ca17ba73f0d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 19:19:23 -0700 Subject: [PATCH 396/428] terminal: add a test for resize less cols across pages with cursor --- src/terminal/PageList.zig | 192 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 77e0a78079..9479a847f4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -5280,6 +5280,198 @@ test "PageList resize reflow more cols wrap across page boundary" { } } +test "PageList resize reflow more cols wrap across page boundary cursor in second page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(1); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in wrapped row on the last page + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 9 } }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.last.?); + + // We expect one extra row since we unwrapped a row we need to resize + // to make our active area. + const end_rows = s.totalRows(); + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, end_rows), s.totalRows()); + + // Our cursor should move to the first row + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 9, + } }, s.pointFromPin(.active, p.*).?); + + { + const p2 = s.pin(.{ .active = .{ .y = 9 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(!row.wrap); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint); + } +} + +test "PageList resize reflow less cols wrap across page boundary cursor in second page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 10, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Grow to the capacity of the first page. + { + const page = &s.pages.first.?.data; + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(5); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // At this point, we have some rows on the first page, and some on the second. + // We can now wrap across the boundary condition. + { + const page = &s.pages.first.?.data; + const y = page.size.rows - 1; + { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + { + const page2 = &s.pages.last.?.data; + const y = 0; + { + const rac = page2.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + + // Put a tracked pin in wrapped row on the last page + const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 5 } }).?); + defer s.untrackPin(p); + try testing.expect(p.page == s.pages.last.?); + try testing.expect(p.y == 0); + + // Resize + try s.resize(.{ + .cols = 4, + .reflow = true, + .cursor = .{ .x = 2, .y = 5 }, + }); + try testing.expectEqual(@as(usize, 4), s.cols); + + // Our cursor should remain on the same cell + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 6, + } }, s.pointFromPin(.active, p.*).?); + + { + const p2 = s.pin(.{ .active = .{ .y = 5 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(row.wrap); + try testing.expect(!row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 2), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 3), cells[3].content.codepoint); + } + { + const p2 = s.pin(.{ .active = .{ .y = 6 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(row.wrap); + try testing.expect(row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 4), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 0), cells[1].content.codepoint); + try testing.expectEqual(@as(u21, 1), cells[2].content.codepoint); + try testing.expectEqual(@as(u21, 2), cells[3].content.codepoint); + } + { + const p2 = s.pin(.{ .active = .{ .y = 7 } }).?; + const row = p2.rowAndCell().row; + try testing.expect(!row.wrap); + try testing.expect(row.wrap_continuation); + + const cells = p2.cells(.all); + try testing.expectEqual(@as(u21, 3), cells[0].content.codepoint); + try testing.expectEqual(@as(u21, 4), cells[1].content.codepoint); + } +} test "PageList resize reflow more cols cursor in wrapped row" { const testing = std.testing; const alloc = testing.allocator; From 36240b897ca17819a3cb47027191af2c6f48df6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 20:27:47 -0700 Subject: [PATCH 397/428] terminal: many more assertions around spacer state --- src/terminal/Screen.zig | 11 +++++-- src/terminal/Terminal.zig | 17 +++++++++- src/terminal/page.zig | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 51e86d7825..6b09823a4c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -820,6 +820,15 @@ pub fn clearCells( row: *Row, cells: []Cell, ) void { + // This whole operation does unsafe things, so we just want to assert + // the end state. + page.pauseIntegrityChecks(true); + defer { + page.pauseIntegrityChecks(false); + page.assertIntegrity(); + self.assertIntegrity(); + } + // If this row has graphemes, then we need go through a slow path // and delete the cell graphemes. if (row.grapheme) { @@ -866,8 +875,6 @@ pub fn clearCells( } @memset(cells, self.blankCell()); - page.assertIntegrity(); - self.assertIntegrity(); } /// Clear cells but only if they are not protected. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 72b03c12f4..ebcf06bf96 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -536,6 +536,12 @@ fn printCell( .spacer_tail => { assert(self.screen.cursor.x > 0); + // So integrity checks pass. We fix this up later so we don't + // need to do this without safety checks. + if (comptime std.debug.runtime_safety) { + cell.wide = .narrow; + } + const wide_cell = self.screen.cursorCellLeft(1); self.screen.clearCells( &self.screen.cursor.page_pin.page.data, @@ -1564,13 +1570,22 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // "scroll_amount" is the number of such cols. const scroll_amount = rem - adjusted_count; if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + 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]); + const end_multi: [*]Cell = @ptrCast(end); + assert(end_multi[1].wide == .spacer_tail); + self.screen.clearCells( + page, + self.screen.cursor.page_row, + end_multi[0..2], + ); } // We work backwards so we don't overwrite data. diff --git a/src/terminal/page.zig b/src/terminal/page.zig index ea0192b334..b4c75121f2 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -100,6 +100,11 @@ pub const Page = struct { /// memory and is fixed at page creation time. capacity: Capacity, + /// If this is true then verifyIntegrity will do nothing. This is + /// only present with runtime safety enabled. + pause_integrity_checks: if (std.debug.runtime_safety) bool else void = + if (std.debug.runtime_safety) false else void, + /// Initialize a new page, allocating the required backing memory. /// The size of the initialized page defaults to the full capacity. /// @@ -191,8 +196,18 @@ pub const Page = struct { UnmarkedStyleRow, MismatchedStyleRef, InvalidStyleCount, + InvalidSpacerTailLocation, + InvalidSpacerHeadLocation, + UnwrappedSpacerHead, }; + /// Temporarily pause integrity checks. This is useful when you are + /// doing a lot of operations that would trigger integrity check + /// violations but you know the page will end up in a consistent state. + pub fn pauseIntegrityChecks(self: *Page, v: bool) void { + if (comptime std.debug.runtime_safety) self.pause_integrity_checks = v; + } + /// A helper that can be used to assert the integrity of the page /// when runtime safety is enabled. This is a no-op when runtime /// safety is disabled. This uses the libc allocator. @@ -220,6 +235,10 @@ pub const Page = struct { // used for the same reason as styles above. // + if (comptime std.debug.runtime_safety) { + if (self.pause_integrity_checks) return; + } + if (self.size.rows == 0) { log.warn("page integrity violation zero row count", .{}); return IntegrityError.ZeroRowCount; @@ -282,6 +301,53 @@ pub const Page = struct { if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += 1; } + + switch (cell.wide) { + .narrow => {}, + .wide => {}, + + .spacer_tail => { + // Spacer tails can't be at the start because they follow + // a wide char. + if (x == 0) { + log.warn( + "page integrity violation y={} x={} spacer tail at start", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + + // Spacer tails must follow a wide char + const prev = cells[x - 1]; + if (prev.wide != .wide) { + log.warn( + "page integrity violation y={} x={} spacer tail not following wide", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + }, + + .spacer_head => { + // Spacer heads must be at the end + if (x != self.size.cols - 1) { + log.warn( + "page integrity violation y={} x={} spacer head not at end", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerHeadLocation; + } + + // The row must be wrapped + if (!row.wrap) { + log.warn( + "page integrity violation y={} spacer head not wrapped", + .{y}, + ); + return IntegrityError.UnwrappedSpacerHead; + } + }, + } } // Check row grapheme data From d1a0149982223054625a1c7342f4cf851a624897 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 20:47:04 -0700 Subject: [PATCH 398/428] terminal: deleteChars must not shift a spacer head --- src/terminal/Terminal.zig | 81 ++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ebcf06bf96..476eba5b15 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1608,8 +1608,8 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { /// scrolling region, it is adjusted down. /// /// Does not change the cursor position. -pub fn deleteChars(self: *Terminal, count: usize) void { - if (count == 0) return; +pub fn deleteChars(self: *Terminal, count_req: usize) void { + if (count_req == 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. @@ -1634,23 +1634,36 @@ pub fn deleteChars(self: *Terminal, count: usize) void { 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); + const count = @min(count_req, 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; + const scroll_amount = rem - count; var x: [*]Cell = left; if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + 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]); + switch (end.wide) { + .narrow, .wide => {}, + + // If our end is a spacer head then we need to clear it since + // spacer heads must be at the end. + .spacer_head => { + self.screen.clearCells(page, self.screen.cursor.page_row, end[0..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. + .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]); + }, } // If our first cell is a wide char then we need to also clear @@ -6742,6 +6755,54 @@ test "Terminal: deleteChars split wide character from wide" { } } +test "Terminal: deleteChars split wide character from end" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("A橋123"); + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x6A4B), 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(@as(u21, ' '), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: deleteChars with a spacer head at the end" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("0123橋123"); + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const row = list_cell.row; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(row.wrap); + } + + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .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: deleteChars split wide character tail" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); From db3ab4b0c87b65db516d407589f501006cba81f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 20:57:35 -0700 Subject: [PATCH 399/428] terminal: pause page integrity can be nested --- src/terminal/page.zig | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index b4c75121f2..74f81b3f81 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -102,8 +102,8 @@ pub const Page = struct { /// If this is true then verifyIntegrity will do nothing. This is /// only present with runtime safety enabled. - pause_integrity_checks: if (std.debug.runtime_safety) bool else void = - if (std.debug.runtime_safety) false else void, + pause_integrity_checks: if (std.debug.runtime_safety) usize else void = + if (std.debug.runtime_safety) 0 else void, /// Initialize a new page, allocating the required backing memory. /// The size of the initialized page defaults to the full capacity. @@ -205,7 +205,13 @@ pub const Page = struct { /// doing a lot of operations that would trigger integrity check /// violations but you know the page will end up in a consistent state. pub fn pauseIntegrityChecks(self: *Page, v: bool) void { - if (comptime std.debug.runtime_safety) self.pause_integrity_checks = v; + if (comptime std.debug.runtime_safety) { + if (v) { + self.pause_integrity_checks += 1; + } else { + self.pause_integrity_checks -= 1; + } + } } /// A helper that can be used to assert the integrity of the page @@ -236,7 +242,7 @@ pub const Page = struct { // if (comptime std.debug.runtime_safety) { - if (self.pause_integrity_checks) return; + if (self.pause_integrity_checks > 0) return; } if (self.size.rows == 0) { From 3e84591b840c614ec87721803721f7bc723d842b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 21:27:45 -0700 Subject: [PATCH 400/428] terminal: insertBlanks doesn't split spacer tail --- src/terminal/Terminal.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 476eba5b15..c7d3b159ea 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1559,6 +1559,13 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { 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; @@ -6434,6 +6441,22 @@ test "Terminal: insertBlanks shift graphemes" { try testing.expectEqual(@as(usize, 1), page.graphemeCount()); } +test "Terminal: insertBlanks split multi-cell character from tail" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("橋123"); + t.setCursorPos(1, 2); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 12", str); + } +} + test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 2 }); From 33ede13072c500eead5c3bce6914d8e013a9d406 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 24 Mar 2024 21:28:30 -0700 Subject: [PATCH 401/428] terminal: fix release builds --- src/terminal/page.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 74f81b3f81..e09a646e85 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -103,7 +103,7 @@ pub const Page = struct { /// If this is true then verifyIntegrity will do nothing. This is /// only present with runtime safety enabled. pause_integrity_checks: if (std.debug.runtime_safety) usize else void = - if (std.debug.runtime_safety) 0 else void, + if (std.debug.runtime_safety) 0 else {}, /// Initialize a new page, allocating the required backing memory. /// The size of the initialized page defaults to the full capacity. From 9ee0b23ef7020c1288eec655b7bbc606f1755355 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 09:42:05 -0700 Subject: [PATCH 402/428] terminal: clear spacer heads on growing cols w/o reflow --- src/terminal/PageList.zig | 72 +++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 44 ++++++++++++++---------- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9479a847f4..6ed34328d3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4826,6 +4826,78 @@ test "PageList resize (no reflow) more cols" { } } +test "PageList resize (no reflow) more cols with spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + rac.row.wrap = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'x' }, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_head, + }; + } + { + const rac = page.getRowAndCell(0, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '😀' }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(1, 1); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = ' ' }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = false }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 3), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + // try testing.expect(!rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, ' '), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} + // This test is a bit convoluted so I want to explain: what we are trying // to verify here is that when we increase cols such that our rows per page // shrinks, we don't fragment our rows across many pages because this ends diff --git a/src/terminal/page.zig b/src/terminal/page.zig index e09a646e85..58f30730ca 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -494,26 +494,34 @@ pub const Page = struct { // If we have no managed memory in the row, we can just copy. if (!dst_row.grapheme and !dst_row.styled) { fastmem.copy(Cell, cells, other_cells); - return; + } else { + // We have managed memory, so we have to do a slower copy to + // get all of that right. + for (cells, other_cells) |*dst_cell, *src_cell| { + dst_cell.* = src_cell.*; + if (src_cell.hasGrapheme()) { + // To prevent integrity checks flipping + if (comptime std.debug.runtime_safety) dst_cell.style_id = style.default_id; + + dst_cell.content_tag = .codepoint; // required for appendGrapheme + const cps = other.lookupGrapheme(src_cell).?; + for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); + } + if (src_cell.style_id != style.default_id) { + const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; + const md = try self.styles.upsert(self.memory, other_style); + md.ref += 1; + dst_cell.style_id = md.id; + } + } } - // We have managed memory, so we have to do a slower copy to - // get all of that right. - for (cells, other_cells) |*dst_cell, *src_cell| { - dst_cell.* = src_cell.*; - if (src_cell.hasGrapheme()) { - // To prevent integrity checks flipping - if (comptime std.debug.runtime_safety) dst_cell.style_id = style.default_id; - - dst_cell.content_tag = .codepoint; // required for appendGrapheme - const cps = other.lookupGrapheme(src_cell).?; - for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); - } - if (src_cell.style_id != style.default_id) { - const other_style = other.styles.lookupId(other.memory, src_cell.style_id).?.*; - const md = try self.styles.upsert(self.memory, other_style); - md.ref += 1; - dst_cell.style_id = md.id; + // If we are growing columns, then we need to ensure spacer heads + // are cleared. + if (self.size.cols > other.size.cols) { + const last = &cells[other.size.cols - 1]; + if (last.wide == .spacer_head) { + last.wide = .narrow; } } From dc858980de7fb29dcca77b34ff39911c3eb4893d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 09:54:17 -0700 Subject: [PATCH 403/428] terminal: deleteChars resets row wrap state --- src/terminal/Terminal.zig | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c7d3b159ea..faac7ec02c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1623,6 +1623,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { if (self.screen.cursor.x < self.scrolling_region.left or self.screen.cursor.x > self.scrolling_region.right) return; + // 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; @@ -6638,7 +6641,7 @@ test "Terminal: deleteChars should shift left" { } } -test "Terminal: deleteChars resets wrap" { +test "Terminal: deleteChars resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); @@ -6656,6 +6659,35 @@ test "Terminal: deleteChars resets wrap" { } } +test "Terminal: deleteChars resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 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.deleteChars(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("XCDE\n123", str); + } +} + test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 10 }); From a58b03c5a0c6307b9a9d10f463c0e413c4ef1393 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 10:06:14 -0700 Subject: [PATCH 404/428] terminal: insertLines clears row wrap state --- src/terminal/Terminal.zig | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index faac7ec02c..9079b2c89a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1337,6 +1337,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { unreachable; }; + // Row never is wrapped + dst.wrap = false; + continue; } @@ -1346,6 +1349,10 @@ pub fn insertLines(self: *Terminal, count: usize) void { dst.* = src.*; src.* = dst_row; + // Row never is wrapped + dst.wrap = false; + src.wrap = false; + // Ensure what we did didn't corrupt the page p.page.data.assertIntegrity(); continue; @@ -1362,6 +1369,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.scrolling_region.left, (self.scrolling_region.right - self.scrolling_region.left) + 1, ); + + // Row never is wrapped + dst.wrap = false; } // The operations above can prune our cursor style so we need to @@ -4237,7 +4247,7 @@ test "Terminal: insertLines more than remaining" { } } -test "Terminal: insertLines resets wrap" { +test "Terminal: insertLines resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); @@ -4255,6 +4265,32 @@ test "Terminal: insertLines resets wrap" { } } +test "Terminal: insertLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + 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\n1\nABC", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + test "Terminal: insertLines multi-codepoint graphemes" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); From 62abecd49d14321e48ae677f078287eaad4c00ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 10:09:23 -0700 Subject: [PATCH 405/428] terminal: deleteLines resets line wrap --- src/terminal/Terminal.zig | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9079b2c89a..68570ac88c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1475,6 +1475,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { unreachable; }; + // Row never is wrapped + dst.wrap = false; + continue; } @@ -1484,6 +1487,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { dst.* = src.*; src.* = dst_row; + // Row never is wrapped + dst.wrap = false; + // Ensure what we did didn't corrupt the page p.page.data.assertIntegrity(); continue; @@ -1500,6 +1506,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { self.scrolling_region.left, (self.scrolling_region.right - self.scrolling_region.left) + 1, ); + + // Row never is wrapped + dst.wrap = false; } // The operations above can prune our cursor style so we need to @@ -5950,7 +5959,7 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { } } -test "Terminal: deleteLines resets wrap" { +test "Terminal: deleteLines resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); @@ -5968,6 +5977,34 @@ test "Terminal: deleteLines resets wrap" { } } +test "Terminal: deleteLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + t.deleteLines(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC\n\nDEF", str); + } + + for (0..t.rows) |y| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 10 }); From 705bd210552fc89d3d22514624c3eb720236f70b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 10:18:31 -0700 Subject: [PATCH 406/428] terminal: PageList trim blanks erases empty pages Fixes #1605 --- src/terminal/PageList.zig | 40 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 6ed34328d3..ea6188b951 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1554,7 +1554,12 @@ fn trimTrailingBlankRows( // no text we can also be sure it has no styling // so we don't need to worry about memory. row_pin.page.data.size.rows -= 1; - row_pin.page.data.assertIntegrity(); + if (row_pin.page.data.size.rows == 0) { + self.erasePage(row_pin.page); + } else { + row_pin.page.data.assertIntegrity(); + } + trimmed += 1; if (trimmed >= max) return trimmed; } @@ -4687,6 +4692,39 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li } }, s.pointFromPin(.active, p.*).?); } +test "PageList resize (no reflow) less rows trims blank lines erases pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 100, 5, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Resize to take up two pages + { + const rows = page.capacity.rows + 10; + try s.resize(.{ .rows = rows, .reflow = false }); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + } + + // Write codepoint into first line + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + + // Resize down. Every row except the first is blank so we + // should erase the second page. + try s.resize(.{ .rows = 5, .reflow = false }); + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 5), s.totalRows()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + test "PageList resize (no reflow) more rows extends blank lines" { const testing = std.testing; const alloc = testing.allocator; From 59048668bb6ea003bc61cdbf98a92b3c42f7abd7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 10:55:27 -0700 Subject: [PATCH 407/428] ci: try PR builds on Namespace --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 48d338b63f..f7b26e5b93 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -8,7 +8,7 @@ name: Release PR jobs: build-macos: - runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest + runs-on: namespace-profile-ghostty-macos timeout-minutes: 90 steps: - name: Checkout code From 41720b3c8dea2c04116aa3faee1708a394fee9a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 11:06:21 -0700 Subject: [PATCH 408/428] terminal: PageList support initialization of multi-page viewports --- src/terminal/PageList.zig | 90 ++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ea6188b951..8086e93086 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -223,30 +223,7 @@ pub fn init( // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); errdefer pool.deinit(); - - var page = try pool.nodes.create(); - const page_buf = try pool.pages.create(); - // no errdefer because the pool deinit will clean these up - - // In runtime safety modes we have to memset because the Zig allocator - // interface will always memset to 0xAA for undefined. In non-safe modes - // we use a page allocator and the OS guarantees zeroed memory. - if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - - // Initialize the first set of pages to contain our viewport so that - // the top of the first page is always the active area. - page.* = .{ - .data = Page.initBuf( - OffsetBuf.init(page_buf), - Page.layout(try std_capacity.adjust(.{ .cols = cols })), - ), - }; - assert(page.data.capacity.rows >= rows); // todo: handle this - page.data.size.rows = rows; - - var page_list: List = .{}; - page_list.prepend(page); - const page_size = page_buf.len; + const page_list, const page_size = try initPages(&pool, cols, rows); // Get our minimum max size, see doc comments for more details. const min_max_size = try minMaxSize(cols, rows); @@ -272,6 +249,48 @@ pub fn init( }; } +fn initPages( + pool: *MemoryPool, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !struct { List, usize } { + var page_list: List = .{}; + var page_size: usize = 0; + + // Add pages as needed to create our initial viewport. + const cap = try std_capacity.adjust(.{ .cols = cols }); + var rem = rows; + while (rem > 0) { + const page = try pool.nodes.create(); + const page_buf = try pool.pages.create(); + // no errdefer because the pool deinit will clean these up + + // In runtime safety modes we have to memset because the Zig allocator + // interface will always memset to 0xAA for undefined. In non-safe modes + // we use a page allocator and the OS guarantees zeroed memory. + if (comptime std.debug.runtime_safety) @memset(page_buf, 0); + + // Initialize the first set of pages to contain our viewport so that + // the top of the first page is always the active area. + page.* = .{ + .data = Page.initBuf( + OffsetBuf.init(page_buf), + Page.layout(cap), + ), + }; + page.data.size.rows = @min(rem, page.data.capacity.rows); + rem -= page.data.size.rows; + + // Add the page to the list + page_list.append(page); + page_size += page_buf.len; + } + + assert(page_list.first != null); + + return .{ page_list, page_size }; +} + /// Deinit the pagelist. If you own the memory pool (used clonePool) then /// this will reset the pool and retain capacity. pub fn deinit(self: *PageList) void { @@ -3029,6 +3048,29 @@ test "PageList" { }, s.getTopLeft(.active)); } +test "PageList init rows across two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // Find a cap that makes it so that rows don't fit on one page. + const rows = 100; + const cap = cap: { + var cap = try std_capacity.adjust(.{ .cols = 50 }); + while (cap.rows >= rows) cap = try std_capacity.adjust(.{ + .cols = cap.cols + 50, + }); + + break :cap cap; + }; + + // Init + var s = try init(alloc, cap.cols, rows, null); + defer s.deinit(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + test "PageList pointFromPin active no history" { const testing = std.testing; const alloc = testing.allocator; From efa18d69717d969850e3efce9c7c25aca619903e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 11:06:51 -0700 Subject: [PATCH 409/428] Revert "ci: try PR builds on Namespace" This reverts commit 59048668bb6ea003bc61cdbf98a92b3c42f7abd7. --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index f7b26e5b93..48d338b63f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -8,7 +8,7 @@ name: Release PR jobs: build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest timeout-minutes: 90 steps: - name: Checkout code From fe43462eb33db0427b4e012427c4ee95793c0173 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 11:20:28 -0700 Subject: [PATCH 410/428] terminal: address todo to re-resolve 905 --- src/terminal/Terminal.zig | 62 +++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 68570ac88c..0c15c2f70f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1890,35 +1890,39 @@ pub fn eraseDisplay( // 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. - // }; - // } + if (self.active_screen == .primary) at_prompt: { + // Go from the bottom of the active up and see if we're + // at a prompt. + const active_br = self.screen.pages.getBottomRight( + .active, + ) orelse break :at_prompt; + var it = active_br.rowIterator( + .left_up, + self.screen.pages.getTopLeft(.active), + ); + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // 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.scrollClear() 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( From e337ebe131fbdb7dc1bfd4f79594f9b6ad52aa9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 20:01:28 -0700 Subject: [PATCH 411/428] terminal: add clonePartialRowFrom --- src/terminal/page.zig | 232 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 10 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 58f30730ca..90148e85d8 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -473,26 +473,60 @@ pub const Page = struct { other: *const Page, dst_row: *Row, src_row: *const Row, + ) !void { + try self.clonePartialRowFrom( + other, + dst_row, + src_row, + 0, + self.size.cols, + ); + } + + /// Clone a single row from another page into this page, supporting + /// partial copy. cloneRowFrom calls this. + pub fn clonePartialRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + x_start: usize, + x_end_req: usize, ) !void { const cell_len = @min(self.size.cols, other.size.cols); - const other_cells = src_row.cells.ptr(other.memory)[0..cell_len]; - const cells = dst_row.cells.ptr(self.memory)[0..cell_len]; + const x_end = @min(x_end_req, cell_len); + assert(x_start <= x_end); + const other_cells = src_row.cells.ptr(other.memory)[x_start..x_end]; + const cells = dst_row.cells.ptr(self.memory)[x_start..x_end]; // If our destination has styles or graphemes then we need to // clear some state. if (dst_row.grapheme or dst_row.styled) { - self.clearCells(dst_row, 0, cells.len); - assert(!dst_row.grapheme); - assert(!dst_row.styled); + self.clearCells(dst_row, x_start, x_end); } // Copy all the row metadata but keep our cells offset - const cells_offset = dst_row.cells; - dst_row.* = src_row.*; - dst_row.cells = cells_offset; + dst_row.* = copy: { + var copy = src_row.*; + + // If we're not copying the full row then we want to preserve + // some original state from our dst row. + if ((x_end - x_start) < self.size.cols) { + copy.wrap = dst_row.wrap; + copy.wrap_continuation = dst_row.wrap_continuation; + copy.grapheme = dst_row.grapheme; + copy.styled = dst_row.styled; + } + + // Our cell offset remains the same + copy.cells = dst_row.cells; - // If we have no managed memory in the row, we can just copy. - if (!dst_row.grapheme and !dst_row.styled) { + break :copy copy; + }; + + // If we have no managed memory in the source, then we can just + // copy it directly. + if (!src_row.grapheme and !src_row.styled) { fastmem.copy(Cell, cells, other_cells); } else { // We have managed memory, so we have to do a slower copy to @@ -512,6 +546,7 @@ pub const Page = struct { const md = try self.styles.upsert(self.memory, other_style); md.ref += 1; dst_cell.style_id = md.id; + dst_row.styled = true; } } } @@ -1642,6 +1677,183 @@ test "Page cloneFrom frees dst graphemes" { try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); } +test "Page cloneRowFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + } +} + +test "Page cloneRowFrom partial grapheme in non-copied source region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + { + const rac = page.getRowAndCell(0, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page.getRowAndCell(9, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try testing.expectEqual(@as(usize, 2), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + try testing.expect(!rac.cell.hasGrapheme()); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(!rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial grapheme in non-copied dest region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + { + const y = 0; + for (0..page2.size.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0xBB }, + }; + } + { + const rac = page2.getRowAndCell(0, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page2.getRowAndCell(9, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0xBB; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 2), page2.graphemeCount()); +} + test "Page moveCells text-only" { var page = try Page.init(.{ .cols = 10, From ad5d7b6c5a459e479fadad9d1151745fb64ac94f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 20:07:19 -0700 Subject: [PATCH 412/428] terminal: insert/deleteLines with L/R region across pages --- src/terminal/Terminal.zig | 76 +++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0c15c2f70f..ac2ed61e16 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1325,24 +1325,27 @@ pub fn insertLines(self: *Terminal, count: usize) void { const src: *Row = p.rowAndCell().row; const dst: *Row = dst_p.rowAndCell().row; - if (!left_right) { - // If the pages are not the same, we need to do a slow copy. - if (dst_p.page != p.page) { - dst_p.page.data.cloneRowFrom( - &p.page.data, - dst, - src, - ) catch |err| { - std.log.warn("TODO: insertLines handle clone error err={}", .{err}); - unreachable; - }; - - // Row never is wrapped - dst.wrap = false; - - continue; - } + // If our page doesn't match, then we need to do a copy from + // one page to another. This is the slow path. + if (dst_p.page != p.page) { + dst_p.page.data.clonePartialRowFrom( + &p.page.data, + dst, + src, + self.scrolling_region.left, + self.scrolling_region.right + 1, + ) catch |err| { + std.log.warn("TODO: insertLines handle clone error err={}", .{err}); + unreachable; + }; + // Row never is wrapped if we're full width. + if (!left_right) dst.wrap = false; + + continue; + } + + 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.*; @@ -1358,8 +1361,6 @@ pub fn insertLines(self: *Terminal, count: usize) void { continue; } - assert(dst_p.page == p.page); // TODO: handle different pages for left/right - // Left/right scroll margins we have to copy cells, which is much slower... const page = &p.page.data; page.moveCells( @@ -1463,24 +1464,25 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const src: *Row = src_p.rowAndCell().row; const dst: *Row = p.rowAndCell().row; - if (!left_right) { - // If the pages are not the same, we need to do a slow copy. - if (src_p.page != p.page) { - p.page.data.cloneRowFrom( - &src_p.page.data, - dst, - src, - ) catch |err| { - std.log.warn("TODO: deleteLines handle clone error err={}", .{err}); - unreachable; - }; - - // Row never is wrapped - dst.wrap = false; - - continue; - } + if (src_p.page != p.page) { + p.page.data.clonePartialRowFrom( + &src_p.page.data, + dst, + src, + self.scrolling_region.left, + self.scrolling_region.right + 1, + ) catch |err| { + std.log.warn("TODO: deleteLines handle clone error err={}", .{err}); + unreachable; + }; + // Row never is wrapped if we're full width. + if (!left_right) dst.wrap = false; + + continue; + } + + 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.*; @@ -1495,8 +1497,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { continue; } - assert(src_p.page == p.page); // TODO: handle different pages for left/right - // Left/right scroll margins we have to copy cells, which is much slower... const page = &p.page.data; page.moveCells( From fcc0ea0c7c99a47c36c4fcaf8391e63cf280a27a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 20:10:03 -0700 Subject: [PATCH 413/428] terminal: explicit error set for page clone --- src/terminal/page.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 90148e85d8..dc73c9fcee 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -433,6 +433,8 @@ pub const Page = struct { return result; } + pub const CloneFromError = Allocator.Error || style.Set.UpsertError; + /// Clone the contents of another page into this page. The capacities /// can be different, but the size of the other page must fit into /// this page. @@ -450,7 +452,7 @@ pub const Page = struct { other: *const Page, y_start: usize, y_end: usize, - ) !void { + ) CloneFromError!void { assert(y_start <= y_end); assert(y_end <= other.size.rows); assert(y_end - y_start <= self.size.rows); @@ -473,7 +475,7 @@ pub const Page = struct { other: *const Page, dst_row: *Row, src_row: *const Row, - ) !void { + ) CloneFromError!void { try self.clonePartialRowFrom( other, dst_row, @@ -492,7 +494,7 @@ pub const Page = struct { src_row: *const Row, x_start: usize, x_end_req: usize, - ) !void { + ) CloneFromError!void { const cell_len = @min(self.size.cols, other.size.cols); const x_end = @min(x_end_req, cell_len); assert(x_start <= x_end); From 7f1af89abbdd85605260755a617ba1e9f4212536 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 25 Mar 2024 20:11:21 -0700 Subject: [PATCH 414/428] terminal: turn unreachable into todo --- src/terminal/Terminal.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ac2ed61e16..53a95da020 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1336,7 +1336,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.scrolling_region.right + 1, ) catch |err| { std.log.warn("TODO: insertLines handle clone error err={}", .{err}); - unreachable; + @panic("TODO"); }; // Row never is wrapped if we're full width. @@ -1473,7 +1473,7 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { self.scrolling_region.right + 1, ) catch |err| { std.log.warn("TODO: deleteLines handle clone error err={}", .{err}); - unreachable; + @panic("TODO"); }; // Row never is wrapped if we're full width. From 6c0609ddc85b63b91055f6c5835905619d2e27b3 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Mon, 25 Mar 2024 12:03:59 -0500 Subject: [PATCH 415/428] terminal: reset alt screen kitty keyboard state on full reset --- src/terminal/Terminal.zig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c7d3b159ea..1fa6f4e91a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2342,6 +2342,7 @@ pub fn fullReset(self: *Terminal) void { self.tabstops.reset(TABSTOP_INTERVAL); self.screen.clearSelection(); self.screen.kitty_keyboard = .{}; + self.secondary_screen.kitty_keyboard = .{}; self.screen.protected_mode = .off; self.scrolling_region = .{ .top = 0, @@ -8117,6 +8118,25 @@ test "Terminal: fullReset status display" { try testing.expect(t.status_display == .main); } +// https://github.com/mitchellh/ghostty/issues/1607 +test "Terminal: fullReset clears alt screen kitty keyboard state" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.alternateScreen(.{}); + t.screen.kitty_keyboard.push(.{ + .disambiguate = true, + .report_events = false, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }); + t.primaryScreen(.{}); + + t.fullReset(); + try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); +} + // https://github.com/mitchellh/ghostty/issues/272 // This is also tested in depth in screen resize tests but I want to keep // this test around to ensure we don't regress at multiple layers. From 0a6ef3fda4caf96370e9132b0d971b5a52982565 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 14:40:57 -0600 Subject: [PATCH 416/428] wip(terminal): Fast path for scroll regions --- src/terminal/PageList.zig | 78 +++++++++++++++++++++++++++++++++------ src/terminal/Screen.zig | 21 +++++------ 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 8086e93086..dea1a739e5 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1986,6 +1986,60 @@ fn destroyPageExt( pool.nodes.destroy(page); } +/// Fast-path function to erase exactly 1 row. Erasing means that the row +/// is completely removed, not just cleared. All rows below the removed row +/// will be moved up by 1 to account for this. +pub fn eraseRow( + self: *PageList, + pt: point.Point, +) !void { + const pn = self.pin(pt).?; + + var page = pn.page; + var rows = page.data.rows.ptr(page.data.memory.ptr); + + std.mem.rotate(Row, rows[pn.y..page.data.size.rows], 1); + + { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y) p.y -= 1; + } + } + + while (page.next) |next| { + const next_rows = next.data.rows.ptr(next.data.memory.ptr); + try page.data.cloneRowFrom(&next.data, &rows[page.data.size.rows - 1], &next_rows[0]); + + page = next; + rows = next_rows; + + std.mem.rotate(Row, rows[0..page.data.size.rows], 1); + + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + } + + // The final row needs to be cleared in case we re-use it. + page.data.clearCells(&rows[page.data.size.rows - 1], 0, page.data.size.cols); + + // We don't trim off the final row if we erased active, since one of + // our invariants is that we always have full active space. + if (pt != .active) { + page.data.size.rows -= 1; + } +} + /// Erase the rows from the given top to bottom (inclusive). Erasing /// the rows doesn't clear them but actually physically REMOVES the rows. /// If the top or bottom point is in the middle of a page, the other @@ -2040,20 +2094,20 @@ pub fn eraseRows( dst.* = src.*; src.* = old_dst; - // Clear the old data in case we reuse these cells. - chunk.page.data.clearCells(src, 0, chunk.page.data.size.cols); + // // Clear the old data in case we reuse these cells. + // chunk.page.data.clearCells(src, 0, chunk.page.data.size.cols); } - // Clear our remaining cells that we didn't shift or swapped - // in case we grow back into them. - for (scroll_amount..chunk.page.data.size.rows) |i| { - const row: *Row = &rows[i]; - chunk.page.data.clearCells( - row, - 0, - chunk.page.data.size.cols, - ); - } + // // Clear our remaining cells that we didn't shift or swapped + // // in case we grow back into them. + // for (scroll_amount..chunk.page.data.size.rows) |i| { + // const row: *Row = &rows[i]; + // chunk.page.data.clearCells( + // row, + // 0, + // chunk.page.data.size.cols, + // ); + // } // Update any tracked pins to shift their y. If it was in the erased // row then we move it to the top of this page. diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 6b09823a4c..7c5d925f46 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -535,9 +535,15 @@ pub fn cursorDownScroll(self: *Screen) !void { // If we have a single-row screen, we have no rows to shift // so our cursor is in the correct place we just have to clear // the cells. - if (self.pages.rows > 1) { - // Erase rows will shift our rows up - self.pages.eraseRows(.{ .active = .{} }, .{ .active = .{} }); + if (self.pages.rows == 1) { + self.clearCells( + &self.cursor.page_pin.page.data, + self.cursor.page_row, + self.cursor.page_pin.page.data.getCells(self.cursor.page_row), + ); + } else { + // eraseRow will shift everything below it up. + try self.pages.eraseRow(.{ .active = .{} }); // The above may clear our cursor so we need to update that // again. If this fails (highly unlikely) we just reset @@ -561,15 +567,6 @@ pub fn cursorDownScroll(self: *Screen) !void { 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( - &self.cursor.page_pin.page.data, - self.cursor.page_row, - self.cursor.page_pin.page.data.getCells(self.cursor.page_row), - ); } else { const old_pin = self.cursor.page_pin.*; From 9df9c999a7b9f1d674d417ff52c2139b9b4cf34b Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 14:54:49 -0600 Subject: [PATCH 417/428] fix(terminal): clear erased rows Clearing these rows is necessary to avoid memory corruption, but the calls to `clearCells` in the first loop were redundant, since the rows in question are included in the second loop as well. --- src/terminal/PageList.zig | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index dea1a739e5..9010407f75 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2093,21 +2093,18 @@ pub fn eraseRows( const old_dst = dst.*; dst.* = src.*; src.* = old_dst; - - // // Clear the old data in case we reuse these cells. - // chunk.page.data.clearCells(src, 0, chunk.page.data.size.cols); } - // // Clear our remaining cells that we didn't shift or swapped - // // in case we grow back into them. - // for (scroll_amount..chunk.page.data.size.rows) |i| { - // const row: *Row = &rows[i]; - // chunk.page.data.clearCells( - // row, - // 0, - // chunk.page.data.size.cols, - // ); - // } + // Clear our remaining cells that we didn't shift or swapped + // in case we grow back into them. + for (scroll_amount..chunk.page.data.size.rows) |i| { + const row: *Row = &rows[i]; + chunk.page.data.clearCells( + row, + 0, + chunk.page.data.size.cols, + ); + } // Update any tracked pins to shift their y. If it was in the erased // row then we move it to the top of this page. From ddd7f3e70666c4ff0ced3d89c07630ddf66f74e9 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 15:15:56 -0600 Subject: [PATCH 418/428] comments --- src/terminal/PageList.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9010407f75..ce20071fca 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1998,8 +1998,13 @@ pub fn eraseRow( var page = pn.page; var rows = page.data.rows.ptr(page.data.memory.ptr); + // In order to move the following rows up we rotate the rows array by 1. + // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which + // works perfectly to move all of our elements where they belong. std.mem.rotate(Row, rows[pn.y..page.data.size.rows], 1); + // We adjust the tracked pins in this page, moving up any that were below + // the removed row. { var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { @@ -2008,8 +2013,26 @@ pub fn eraseRow( } } + // We iterate through all of the following pages in order to move their + // rows up by 1 as well. while (page.next) |next| { const next_rows = next.data.rows.ptr(next.data.memory.ptr); + + // We take the top row of the page and clone it in to the bottom + // row of the previous page, which gets rid of the top row that was + // rotated down in the previous page, and accounts for the row in + // this page that will be rotated down as well. + // + // rotate -> clone --> rotate -> result + // 0 -. 1 1 1 + // 1 | 2 2 2 + // 2 | 3 3 3 + // 3 <' 0 <. 4 4 + // --- --- | --- --- + // 4 4 -' 4 -. 5 + // 5 5 5 | 6 + // 6 6 6 | 7 + // 7 7 7 <' 4 try page.data.cloneRowFrom(&next.data, &rows[page.data.size.rows - 1], &next_rows[0]); page = next; @@ -2017,6 +2040,9 @@ pub fn eraseRow( std.mem.rotate(Row, rows[0..page.data.size.rows], 1); + // Our tracked pins for this page need to be updated. + // If the pin is in row 0 that means the corresponding row has + // been moved to the previous page. Otherwise, move it up by 1. var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { const p = p_ptr.*; From d74ea890564ed018769fecd929226357feef2ff4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 19:12:55 -0600 Subject: [PATCH 419/428] fastmem: rotateOnce --- src/fastmem.zig | 8 ++++++++ src/terminal/PageList.zig | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/fastmem.zig b/src/fastmem.zig index 0e9a444ee6..d29087956d 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -22,5 +22,13 @@ pub inline fn copy(comptime T: type, dest: []T, source: []const T) void { } } +/// Same as std.mem.rotate(T, items, 1) but more efficient by using memmove +/// and a tmp var for the single rotated item instead of 3 calls to reverse. +pub inline fn rotateOnce(comptime T: type, items: []T) void { + const tmp = items[0]; + move(T, items[0..items.len - 1], items[1..items.len]); + items[items.len - 1] = tmp; +} + extern "c" fn memcpy(*anyopaque, *const anyopaque, usize) *anyopaque; extern "c" fn memmove(*anyopaque, *const anyopaque, usize) *anyopaque; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ce20071fca..9b6caceaf7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -6,6 +6,7 @@ const PageList = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const fastmem = @import("../fastmem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -2001,7 +2002,7 @@ pub fn eraseRow( // In order to move the following rows up we rotate the rows array by 1. // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which // works perfectly to move all of our elements where they belong. - std.mem.rotate(Row, rows[pn.y..page.data.size.rows], 1); + fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); // We adjust the tracked pins in this page, moving up any that were below // the removed row. @@ -2038,7 +2039,7 @@ pub fn eraseRow( page = next; rows = next_rows; - std.mem.rotate(Row, rows[0..page.data.size.rows], 1); + fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has From 23d32e248e6859158712cd4ce208d7af3f5a859f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 19:16:34 -0600 Subject: [PATCH 420/428] perf(terminal): fast-paths for scrolling regions --- src/terminal/PageList.zig | 103 ++++++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 43 +++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9b6caceaf7..83df038fe7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2067,6 +2067,109 @@ pub fn eraseRow( } } +/// A special-case of eraseRow that shifts only a bounded number of following +/// rows up, filling the space they leave behind with blank rows. +/// +/// `limit` is exclusive of the erased row. A limit of 1 will erase the target +/// row and shift the row below in to its position, leaving a blank row below. +/// +/// This function has a lot of repeated code in it because it is a hot path. +pub fn eraseRowBounded( + self: *PageList, + pt: point.Point, + limit: usize, +) !void { + const pn = self.pin(pt).?; + + var page = pn.page; + var rows = page.data.rows.ptr(page.data.memory.ptr); + + // Special case where we'll reach the limit in the same page as the erased + // row, so we don't have to handle cloning rows between pages. + if (page.data.size.rows - pn.y > limit) { + page.data.clearCells(&rows[pn.y], 0, page.data.size.cols); + fastmem.rotateOnce(Row, rows[pn.y..][0..limit + 1]); + + // Update pins in the shifted region. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y and p.y < pn.y + limit) p.y -= 1; + } + + return; + } + + var shifted: usize = 0; + + fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); + + shifted += page.data.size.rows - pn.y; + + // We adjust the tracked pins in this page, moving up any that were below + // the removed row. + { + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page == page and p.y > pn.y) p.y -= 1; + } + } + + while (page.next) |next| { + const next_rows = next.data.rows.ptr(next.data.memory.ptr); + + try page.data.cloneRowFrom(&next.data, &rows[page.data.size.rows - 1], &next_rows[0]); + + page = next; + rows = next_rows; + + const shifted_limit = limit - shifted; + if (page.data.size.rows > shifted_limit) { + page.data.clearCells(&rows[0], 0, page.data.size.cols); + fastmem.rotateOnce(Row, rows[0..shifted_limit + 1]); + + // Update pins in the shifted region. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page or p.y > shifted_limit) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + + return; + } + + fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); + + shifted += page.data.size.rows; + + // Our tracked pins for this page need to be updated. + // If the pin is in row 0 that means the corresponding row has + // been moved to the previous page. Otherwise, move it up by 1. + var pin_it = self.tracked_pins.keyIterator(); + while (pin_it.next()) |p_ptr| { + const p = p_ptr.*; + if (p.page != page) continue; + if (p.y == 0) { + p.page = page.prev.?; + p.y = p.page.data.size.rows - 1; + continue; + } + p.y -= 1; + } + } + + // We reached the end of the page list before the limit, so we clear + // the final row since it was rotated down from the top of this page. + page.data.clearCells(&rows[page.data.size.rows - 1], 0, page.data.size.cols); +} + /// Erase the rows from the given top to bottom (inclusive). Erasing /// the rows doesn't clear them but actually physically REMOVES the rows. /// If the top or bottom point is in the middle of a page, the other diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9890866733..efce17816a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1084,7 +1084,48 @@ pub fn index(self: *Terminal) !void { { try self.screen.cursorDownScroll(); } else { - self.scrollUp(1); + // Slow path for left and right scrolling region margins. + if (self.scrolling_region.left != 0 and + self.scrolling_region.right != self.cols - 1) + { + self.scrollUp(1); + return; + } + + // Otherwise use a fast path function from PageList to efficiently + // scroll the contents of the scrolling region. + if (self.scrolling_region.bottom < self.rows) { + try self.screen.pages.eraseRowBounded( + .{ .active = .{ .y = self.scrolling_region.top } }, + self.scrolling_region.bottom - self.scrolling_region.top + ); + } else { + // If we have no bottom margin we don't need to worry about + // potentially damaging rows below the scrolling region, + // and eraseRow is cheaper than eraseRowBounded. + try self.screen.pages.eraseRow( + .{ .active = .{ .y = self.scrolling_region.top } }, + ); + } + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + self.screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + self.screen.cursor.style = .{}; + self.screen.manualStyleUpdate() catch unreachable; + }; + + // We scrolled with the cursor on the bottom row of the scrolling + // region, so we should move the cursor to the bottom left. + self.screen.cursorAbsolute( + self.scrolling_region.left, + self.scrolling_region.bottom, + ); + + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; } return; From aadf795d282530105b78d0a976ddbf5ba63e687e Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 19:38:39 -0600 Subject: [PATCH 421/428] fix(terminal): correctly use slow path for left/right scroll margin --- src/terminal/Terminal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index efce17816a..a541c4e07b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1085,7 +1085,7 @@ pub fn index(self: *Terminal) !void { try self.screen.cursorDownScroll(); } else { // Slow path for left and right scrolling region margins. - if (self.scrolling_region.left != 0 and + if (self.scrolling_region.left != 0 or self.scrolling_region.right != self.cols - 1) { self.scrollUp(1); From 2274b8a912f772e8b25b52471563028ccae19a69 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 25 Mar 2024 19:39:35 -0600 Subject: [PATCH 422/428] fix(terminal): don't reset x when indexing in scroll region --- src/terminal/Terminal.zig | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a541c4e07b..43299df458 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1094,6 +1094,15 @@ pub fn index(self: *Terminal) !void { // Otherwise use a fast path function from PageList to efficiently // scroll the contents of the scrolling region. + + // eraseRow and eraseRowBounded will end up moving the cursor pin + // up by 1, so we save its current position and restore it after. + const cursor_x = self.screen.cursor.x; + const cursor_y = self.screen.cursor.y; + defer { + self.screen.cursorAbsolute(cursor_x, cursor_y); + } + if (self.scrolling_region.bottom < self.rows) { try self.screen.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, @@ -1116,16 +1125,6 @@ pub fn index(self: *Terminal) !void { self.screen.cursor.style = .{}; self.screen.manualStyleUpdate() catch unreachable; }; - - // We scrolled with the cursor on the bottom row of the scrolling - // region, so we should move the cursor to the bottom left. - self.screen.cursorAbsolute( - self.scrolling_region.left, - self.scrolling_region.bottom, - ); - - // Always unset pending wrap - self.screen.cursor.pending_wrap = false; } return; From 492e147e26a98e219ac3b1e4fd712fbcee945478 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 26 Mar 2024 10:19:23 -0600 Subject: [PATCH 423/428] terminal: clean up some code and comments --- src/terminal/PageList.zig | 53 ++++++++++++++++++++++----------------- src/terminal/Terminal.zig | 17 +++---------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 83df038fe7..bacb553b0f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1988,8 +1988,12 @@ fn destroyPageExt( } /// Fast-path function to erase exactly 1 row. Erasing means that the row -/// is completely removed, not just cleared. All rows below the removed row -/// will be moved up by 1 to account for this. +/// is completely REMOVED, not just cleared. All rows following the removed +/// row will be shifted up by 1 to fill the empty space. +/// +/// Unlike eraseRows, eraseRow does not change the size of any pages. The +/// caller is responsible for adjusting the row count of the final page if +/// that behavior is required. pub fn eraseRow( self: *PageList, pt: point.Point, @@ -2029,7 +2033,7 @@ pub fn eraseRow( // 1 | 2 2 2 // 2 | 3 3 3 // 3 <' 0 <. 4 4 - // --- --- | --- --- + // --- --- | --- --- <- page boundary // 4 4 -' 4 -. 5 // 5 5 5 | 6 // 6 6 6 | 7 @@ -2057,35 +2061,34 @@ pub fn eraseRow( } } - // The final row needs to be cleared in case we re-use it. + // Clear the final row which was rotated from the top of the page. page.data.clearCells(&rows[page.data.size.rows - 1], 0, page.data.size.cols); - - // We don't trim off the final row if we erased active, since one of - // our invariants is that we always have full active space. - if (pt != .active) { - page.data.size.rows -= 1; - } } -/// A special-case of eraseRow that shifts only a bounded number of following +/// A variant of eraseRow that shifts only a bounded number of following /// rows up, filling the space they leave behind with blank rows. /// /// `limit` is exclusive of the erased row. A limit of 1 will erase the target /// row and shift the row below in to its position, leaving a blank row below. -/// -/// This function has a lot of repeated code in it because it is a hot path. pub fn eraseRowBounded( self: *PageList, pt: point.Point, limit: usize, ) !void { + // This function has a lot of repeated code in it because it is a hot path. + // + // To get a better idea of what's happening, read eraseRow first for more + // in-depth explanatory comments. To avoid repetition, the only comments for + // this function are for where it differs from eraseRow. + const pn = self.pin(pt).?; var page = pn.page; var rows = page.data.rows.ptr(page.data.memory.ptr); - // Special case where we'll reach the limit in the same page as the erased - // row, so we don't have to handle cloning rows between pages. + // If the row limit is less than the remaining rows before the end of the + // page, then we clear the row, rotate it to the end of the boundary limit + // and update our pins. if (page.data.size.rows - pn.y > limit) { page.data.clearCells(&rows[pn.y], 0, page.data.size.cols); fastmem.rotateOnce(Row, rows[pn.y..][0..limit + 1]); @@ -2100,14 +2103,14 @@ pub fn eraseRowBounded( return; } - var shifted: usize = 0; - fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); - shifted += page.data.size.rows - pn.y; + // We need to keep track of how many rows we've shifted so that we can + // determine at what point we need to do a partial shift on subsequent + // pages. + var shifted: usize = page.data.size.rows - pn.y; - // We adjust the tracked pins in this page, moving up any that were below - // the removed row. + // Update tracked pins. { var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { @@ -2124,6 +2127,11 @@ pub fn eraseRowBounded( page = next; rows = next_rows; + // We check to see if this page contains enough rows to satisfy the + // specified limit, accounting for rows we've already shifted in prior + // pages. + // + // The logic here is very similar to the one before the loop. const shifted_limit = limit - shifted; if (page.data.size.rows > shifted_limit) { page.data.clearCells(&rows[0], 0, page.data.size.cols); @@ -2147,11 +2155,10 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); + // Account for the rows shifted in this page. shifted += page.data.size.rows; - // Our tracked pins for this page need to be updated. - // If the pin is in row 0 that means the corresponding row has - // been moved to the previous page. Otherwise, move it up by 1. + // Update tracked pins. var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { const p = p_ptr.*; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 43299df458..52267fa133 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1103,19 +1103,10 @@ pub fn index(self: *Terminal) !void { self.screen.cursorAbsolute(cursor_x, cursor_y); } - if (self.scrolling_region.bottom < self.rows) { - try self.screen.pages.eraseRowBounded( - .{ .active = .{ .y = self.scrolling_region.top } }, - self.scrolling_region.bottom - self.scrolling_region.top - ); - } else { - // If we have no bottom margin we don't need to worry about - // potentially damaging rows below the scrolling region, - // and eraseRow is cheaper than eraseRowBounded. - try self.screen.pages.eraseRow( - .{ .active = .{ .y = self.scrolling_region.top } }, - ); - } + try self.screen.pages.eraseRowBounded( + .{ .active = .{ .y = self.scrolling_region.top } }, + self.scrolling_region.bottom - self.scrolling_region.top + ); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE From d72eb30a26433e24b511c9d25b18334b3ef4fc31 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 26 Mar 2024 12:03:37 -0600 Subject: [PATCH 424/428] fastmem: fix doc comment --- src/fastmem.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmem.zig b/src/fastmem.zig index d29087956d..8f32bc3c88 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -12,7 +12,7 @@ pub inline fn move(comptime T: type, dest: []T, source: []const T) void { } } -/// Same as std.mem.copyForwards but prefers libc memcpy if it is available +/// Same as @memcpy but prefers libc memcpy if it is available /// because it is generally much faster. pub inline fn copy(comptime T: type, dest: []T, source: []const T) void { if (builtin.link_libc) { From d17344b85502952ea793d4b37c44159a13b78cdb Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 26 Mar 2024 12:04:41 -0600 Subject: [PATCH 425/428] perf(terminal/page): @memset micro-optimization --- src/terminal/page.zig | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index dc73c9fcee..d100acc89a 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -182,7 +182,9 @@ pub const Page = struct { /// Reinitialize the page with the same capacity. pub fn reinit(self: *Page) void { - @memset(self.memory, 0); + // We zero the page memory as u64 instead of u8 because + // we can and it's empirically quite a bit faster. + @memset(@as([*]u64, @ptrCast(self.memory))[0..self.memory.len / 8], 0); self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); } @@ -654,7 +656,10 @@ pub const Page = struct { // Clear our source row now that the copy is complete. We can NOT // use clearCells here because clearCells will garbage collect our // styles and graphames but we moved them above. - @memset(src_cells, .{}); + // + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(src_cells, .{}) + @memset(@as([]u64, @ptrCast(src_cells)), 0); if (src_cells.len == self.size.cols) { src_row.grapheme = false; src_row.styled = false; @@ -734,7 +739,9 @@ pub const Page = struct { if (cells.len == self.size.cols) row.styled = false; } - @memset(cells, .{}); + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(cells, .{}) + @memset(@as([]u64, @ptrCast(cells)), 0); } /// Append a codepoint to the given cell as a grapheme. From a416d4236ae32ff8712ffe88b454f804e4f79256 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 Mar 2024 16:14:25 -0700 Subject: [PATCH 426/428] remove old terminal implementation --- src/bench/page-init.zig | 1 - src/bench/resize.sh | 12 - src/bench/resize.zig | 110 - src/bench/screen-copy.sh | 14 - src/bench/screen-copy.zig | 134 - src/bench/stream-new.sh | 31 - src/bench/stream.zig | 50 +- src/bench/vt-insert-lines.sh | 12 - src/bench/vt-insert-lines.zig | 104 - src/build_config.zig | 3 - src/main.zig | 3 - src/main_ghostty.zig | 1 - src/terminal-old/Parser.zig | 794 -- src/terminal-old/Screen.zig | 7925 ----------------- src/terminal-old/Selection.zig | 1165 --- src/terminal-old/StringMap.zig | 124 - src/terminal-old/Tabstops.zig | 231 - src/terminal-old/Terminal.zig | 7632 ---------------- src/terminal-old/UTF8Decoder.zig | 142 - src/terminal-old/ansi.zig | 114 - src/terminal-old/apc.zig | 137 - src/terminal-old/charsets.zig | 114 - src/terminal-old/color.zig | 339 - src/terminal-old/csi.zig | 33 - src/terminal-old/dcs.zig | 309 - src/terminal-old/device_status.zig | 67 - src/terminal-old/kitty.zig | 8 - src/terminal-old/kitty/graphics.zig | 22 - src/terminal-old/kitty/graphics_command.zig | 984 -- src/terminal-old/kitty/graphics_exec.zig | 344 - src/terminal-old/kitty/graphics_image.zig | 776 -- src/terminal-old/kitty/graphics_storage.zig | 865 -- src/terminal-old/kitty/key.zig | 151 - .../image-png-none-50x76-2147483647-raw.data | Bin 86 -> 0 bytes .../image-rgb-none-20x15-2147483647.data | 1 - ...ge-rgb-zlib_deflate-128x96-2147483647.data | 1 - src/terminal-old/main.zig | 54 - src/terminal-old/modes.zig | 247 - src/terminal-old/mouse_shape.zig | 115 - src/terminal-old/osc.zig | 1274 --- src/terminal-old/parse_table.zig | 389 - src/terminal-old/point.zig | 254 - src/terminal-old/res/rgb.txt | 782 -- src/terminal-old/sanitize.zig | 13 - src/terminal-old/sgr.zig | 559 -- src/terminal-old/simdvt.zig | 5 - src/terminal-old/stream.zig | 2014 ----- src/terminal-old/wasm.zig | 32 - src/terminal-old/x11_color.zig | 62 - 49 files changed, 5 insertions(+), 28548 deletions(-) delete mode 100755 src/bench/resize.sh delete mode 100644 src/bench/resize.zig delete mode 100755 src/bench/screen-copy.sh delete mode 100644 src/bench/screen-copy.zig delete mode 100755 src/bench/stream-new.sh delete mode 100755 src/bench/vt-insert-lines.sh delete mode 100644 src/bench/vt-insert-lines.zig delete mode 100644 src/terminal-old/Parser.zig delete mode 100644 src/terminal-old/Screen.zig delete mode 100644 src/terminal-old/Selection.zig delete mode 100644 src/terminal-old/StringMap.zig delete mode 100644 src/terminal-old/Tabstops.zig delete mode 100644 src/terminal-old/Terminal.zig delete mode 100644 src/terminal-old/UTF8Decoder.zig delete mode 100644 src/terminal-old/ansi.zig delete mode 100644 src/terminal-old/apc.zig delete mode 100644 src/terminal-old/charsets.zig delete mode 100644 src/terminal-old/color.zig delete mode 100644 src/terminal-old/csi.zig delete mode 100644 src/terminal-old/dcs.zig delete mode 100644 src/terminal-old/device_status.zig delete mode 100644 src/terminal-old/kitty.zig delete mode 100644 src/terminal-old/kitty/graphics.zig delete mode 100644 src/terminal-old/kitty/graphics_command.zig delete mode 100644 src/terminal-old/kitty/graphics_exec.zig delete mode 100644 src/terminal-old/kitty/graphics_image.zig delete mode 100644 src/terminal-old/kitty/graphics_storage.zig delete mode 100644 src/terminal-old/kitty/key.zig delete mode 100644 src/terminal-old/kitty/testdata/image-png-none-50x76-2147483647-raw.data delete mode 100644 src/terminal-old/kitty/testdata/image-rgb-none-20x15-2147483647.data delete mode 100644 src/terminal-old/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data delete mode 100644 src/terminal-old/main.zig delete mode 100644 src/terminal-old/modes.zig delete mode 100644 src/terminal-old/mouse_shape.zig delete mode 100644 src/terminal-old/osc.zig delete mode 100644 src/terminal-old/parse_table.zig delete mode 100644 src/terminal-old/point.zig delete mode 100644 src/terminal-old/res/rgb.txt delete mode 100644 src/terminal-old/sanitize.zig delete mode 100644 src/terminal-old/sgr.zig delete mode 100644 src/terminal-old/simdvt.zig delete mode 100644 src/terminal-old/stream.zig delete mode 100644 src/terminal-old/wasm.zig delete mode 100644 src/terminal-old/x11_color.zig diff --git a/src/bench/page-init.zig b/src/bench/page-init.zig index c3057cd9f1..e45d64fbbe 100644 --- a/src/bench/page-init.zig +++ b/src/bench/page-init.zig @@ -8,7 +8,6 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const cli = @import("../cli.zig"); -const terminal = @import("../terminal-old/main.zig"); const terminal_new = @import("../terminal/main.zig"); const Args = struct { diff --git a/src/bench/resize.sh b/src/bench/resize.sh deleted file mode 100755 index 8f420bf014..0000000000 --- a/src/bench/resize.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -hyperfine \ - --warmup 10 \ - -n new \ - "./zig-out/bin/bench-resize --mode=new${ARGS}" \ - -n old \ - "./zig-out/bin/bench-resize --mode=old${ARGS}" - diff --git a/src/bench/resize.zig b/src/bench/resize.zig deleted file mode 100644 index d88803fe78..0000000000 --- a/src/bench/resize.zig +++ /dev/null @@ -1,110 +0,0 @@ -//! This benchmark tests the speed of resizing. - -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const cli = @import("../cli.zig"); -const terminal = @import("../terminal-old/main.zig"); -const terminal_new = @import("../terminal/main.zig"); - -const Args = struct { - mode: Mode = .old, - - /// The number of times to loop. - count: usize = 10_000, - - /// Rows and cols in the terminal. - rows: usize = 50, - cols: usize = 100, - - /// This is set by the CLI parser for deinit. - _arena: ?ArenaAllocator = null, - - pub fn deinit(self: *Args) void { - if (self._arena) |arena| arena.deinit(); - self.* = undefined; - } -}; - -const Mode = enum { - /// The default allocation strategy of the structure. - old, - - /// Use a memory pool to allocate pages from a backing buffer. - new, -}; - -pub const std_options: std.Options = .{ - .log_level = .debug, -}; - -pub fn main() !void { - // We want to use the c allocator because it is much faster than GPA. - const alloc = std.heap.c_allocator; - - // Parse our args - var args: Args = .{}; - defer args.deinit(); - { - var iter = try std.process.argsWithAllocator(alloc); - defer iter.deinit(); - try cli.args.parse(Args, alloc, &args, &iter); - } - - // Handle the modes that do not depend on terminal state first. - switch (args.mode) { - .old => { - var t = try terminal.Terminal.init(alloc, args.cols, args.rows); - defer t.deinit(alloc); - try benchOld(&t, args); - }, - - .new => { - var t = try terminal_new.Terminal.init(alloc, .{ - .cols = @intCast(args.cols), - .rows = @intCast(args.rows), - }); - defer t.deinit(alloc); - try benchNew(&t, args); - }, - } -} - -noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |i| { - const cols: usize, const rows: usize = if (i % 2 == 0) - .{ args.cols * 2, args.rows * 2 } - else - .{ args.cols, args.rows }; - - try t.screen.resizeWithoutReflow(@intCast(rows), @intCast(cols)); - } -} - -noinline fn benchNew(t: *terminal_new.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |i| { - const cols: usize, const rows: usize = if (i % 2 == 0) - .{ args.cols * 2, args.rows * 2 } - else - .{ args.cols, args.rows }; - - try t.screen.resizeWithoutReflow(@intCast(rows), @intCast(cols)); - } -} diff --git a/src/bench/screen-copy.sh b/src/bench/screen-copy.sh deleted file mode 100755 index 1bb505d63f..0000000000 --- a/src/bench/screen-copy.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -hyperfine \ - --warmup 10 \ - -n new \ - "./zig-out/bin/bench-screen-copy --mode=new${ARGS}" \ - -n new-pooled \ - "./zig-out/bin/bench-screen-copy --mode=new-pooled${ARGS}" \ - -n old \ - "./zig-out/bin/bench-screen-copy --mode=old${ARGS}" - diff --git a/src/bench/screen-copy.zig b/src/bench/screen-copy.zig deleted file mode 100644 index 15cc76658e..0000000000 --- a/src/bench/screen-copy.zig +++ /dev/null @@ -1,134 +0,0 @@ -//! This benchmark tests the speed of copying the active area of the screen. - -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const cli = @import("../cli.zig"); -const terminal = @import("../terminal-old/main.zig"); -const terminal_new = @import("../terminal/main.zig"); - -const Args = struct { - mode: Mode = .old, - - /// The number of times to loop. - count: usize = 2500, - - /// Rows and cols in the terminal. - rows: usize = 100, - cols: usize = 300, - - /// This is set by the CLI parser for deinit. - _arena: ?ArenaAllocator = null, - - pub fn deinit(self: *Args) void { - if (self._arena) |arena| arena.deinit(); - self.* = undefined; - } -}; - -const Mode = enum { - /// The default allocation strategy of the structure. - old, - - /// Use a memory pool to allocate pages from a backing buffer. - new, - @"new-pooled", -}; - -pub const std_options: std.Options = .{ - .log_level = .debug, -}; - -pub fn main() !void { - // We want to use the c allocator because it is much faster than GPA. - const alloc = std.heap.c_allocator; - - // Parse our args - var args: Args = .{}; - defer args.deinit(); - { - var iter = try std.process.argsWithAllocator(alloc); - defer iter.deinit(); - try cli.args.parse(Args, alloc, &args, &iter); - } - - // Handle the modes that do not depend on terminal state first. - switch (args.mode) { - .old => { - var t = try terminal.Terminal.init(alloc, args.cols, args.rows); - defer t.deinit(alloc); - try benchOld(alloc, &t, args); - }, - - .new => { - var t = try terminal_new.Terminal.init(alloc, .{ - .cols = @intCast(args.cols), - .rows = @intCast(args.rows), - }); - defer t.deinit(alloc); - try benchNew(alloc, &t, args); - }, - - .@"new-pooled" => { - var t = try terminal_new.Terminal.init(alloc, .{ - .cols = @intCast(args.cols), - .rows = @intCast(args.rows), - }); - defer t.deinit(alloc); - try benchNewPooled(alloc, &t, args); - }, - } -} - -noinline fn benchOld(alloc: Allocator, t: *terminal.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |_| { - var s = try t.screen.clone( - alloc, - .{ .active = 0 }, - .{ .active = t.rows - 1 }, - ); - errdefer s.deinit(); - } -} - -noinline fn benchNew(alloc: Allocator, t: *terminal_new.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |_| { - var s = try t.screen.clone(alloc, .{ .active = .{} }, null); - errdefer s.deinit(); - } -} - -noinline fn benchNewPooled(alloc: Allocator, t: *terminal_new.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - var pool = try terminal_new.PageList.MemoryPool.init(alloc, std.heap.page_allocator, 4); - defer pool.deinit(); - - for (0..args.count) |_| { - var s = try t.screen.clonePool(alloc, &pool, .{ .active = .{} }, null); - errdefer s.deinit(); - } -} diff --git a/src/bench/stream-new.sh b/src/bench/stream-new.sh deleted file mode 100755 index b3d7058a10..0000000000 --- a/src/bench/stream-new.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# -# This is a trivial helper script to help run the stream benchmark. -# You probably want to tweak this script depending on what you're -# trying to measure. - -# Options: -# - "ascii", uniform random ASCII bytes -# - "utf8", uniform random unicode characters, encoded as utf8 -# - "rand", pure random data, will contain many invalid code sequences. -DATA="ascii" -SIZE="25000000" - -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -# Generate the benchmark input ahead of time so it's not included in the time. -./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data - -# Uncomment to instead use the contents of `stream.txt` as input. (Ignores SIZE) -# echo $(cat ./stream.txt) > /tmp/ghostty_bench_data - -hyperfine \ - --warmup 10 \ - -n noop \ - "./zig-out/bin/bench-stream --mode=noop = 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); // Handle the modes that do not depend on terminal state first. @@ -122,29 +106,13 @@ pub fn main() !void { inline .scalar, .simd, => |tag| switch (args.terminal) { - .old => { - const TerminalStream = terminal.Stream(*TerminalHandler); - var t = try terminal.Terminal.init( - alloc, - args.@"terminal-cols", - args.@"terminal-rows", - ); - var handler: TerminalHandler = .{ .t = &t }; - var stream: TerminalStream = .{ .handler = &handler }; - switch (tag) { - .scalar => try benchScalar(reader, &stream, buf), - .simd => try benchSimd(reader, &stream, buf), - else => @compileError("missing case"), - } - }, - .new => { - const TerminalStream = terminal.Stream(*NewTerminalHandler); - var t = try terminalnew.Terminal.init(alloc, .{ + const TerminalStream = terminal.Stream(*TerminalHandler); + var t = try terminal.Terminal.init(alloc, .{ .cols = @intCast(args.@"terminal-cols"), .rows = @intCast(args.@"terminal-rows"), }); - var handler: NewTerminalHandler = .{ .t = &t }; + var handler: TerminalHandler = .{ .t = &t }; var stream: TerminalStream = .{ .handler = &handler }; switch (tag) { .scalar => try benchScalar(reader, &stream, buf), @@ -278,11 +246,3 @@ const TerminalHandler = struct { try self.t.print(cp); } }; - -const NewTerminalHandler = struct { - t: *terminalnew.Terminal, - - pub fn print(self: *NewTerminalHandler, cp: u21) !void { - try self.t.print(cp); - } -}; diff --git a/src/bench/vt-insert-lines.sh b/src/bench/vt-insert-lines.sh deleted file mode 100755 index 5c19712cc5..0000000000 --- a/src/bench/vt-insert-lines.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -hyperfine \ - --warmup 10 \ - -n new \ - "./zig-out/bin/bench-vt-insert-lines --mode=new${ARGS}" \ - -n old \ - "./zig-out/bin/bench-vt-insert-lines --mode=old${ARGS}" - diff --git a/src/bench/vt-insert-lines.zig b/src/bench/vt-insert-lines.zig deleted file mode 100644 index d61d5354d5..0000000000 --- a/src/bench/vt-insert-lines.zig +++ /dev/null @@ -1,104 +0,0 @@ -//! This benchmark tests the speed of the "insertLines" operation on a terminal. - -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; -const cli = @import("../cli.zig"); -const terminal = @import("../terminal-old/main.zig"); -const terminal_new = @import("../terminal/main.zig"); - -const Args = struct { - mode: Mode = .old, - - /// The number of times to loop. - count: usize = 15_000, - - /// Rows and cols in the terminal. - rows: usize = 100, - cols: usize = 300, - - /// This is set by the CLI parser for deinit. - _arena: ?ArenaAllocator = null, - - pub fn deinit(self: *Args) void { - if (self._arena) |arena| arena.deinit(); - self.* = undefined; - } -}; - -const Mode = enum { - /// The default allocation strategy of the structure. - old, - - /// Use a memory pool to allocate pages from a backing buffer. - new, -}; - -pub const std_options: std.Options = .{ - .log_level = .debug, -}; - -pub fn main() !void { - // We want to use the c allocator because it is much faster than GPA. - const alloc = std.heap.c_allocator; - - // Parse our args - var args: Args = .{}; - defer args.deinit(); - { - var iter = try std.process.argsWithAllocator(alloc); - defer iter.deinit(); - try cli.args.parse(Args, alloc, &args, &iter); - } - - // Handle the modes that do not depend on terminal state first. - switch (args.mode) { - .old => { - var t = try terminal.Terminal.init(alloc, args.cols, args.rows); - defer t.deinit(alloc); - try benchOld(&t, args); - }, - - .new => { - var t = try terminal_new.Terminal.init(alloc, .{ - .cols = @intCast(args.cols), - .rows = @intCast(args.rows), - }); - defer t.deinit(alloc); - try benchNew(&t, args); - }, - } -} - -noinline fn benchOld(t: *terminal.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |_| { - for (0..args.rows) |i| { - _ = try t.insertLines(i); - } - } -} - -noinline fn benchNew(t: *terminal_new.Terminal, args: Args) !void { - // We fill the terminal with letters. - for (0..args.rows) |row| { - for (0..args.cols) |col| { - t.setCursorPos(row + 1, col + 1); - try t.print('A'); - } - } - - for (0..args.count) |_| { - for (0..args.rows) |i| { - _ = t.insertLines(i); - } - } -} diff --git a/src/build_config.zig b/src/build_config.zig index c894917b9d..742a2b692b 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -144,7 +144,4 @@ pub const ExeEntrypoint = enum { bench_codepoint_width, bench_grapheme_break, bench_page_init, - bench_resize, - bench_screen_copy, - bench_vt_insert_lines, }; diff --git a/src/main.zig b/src/main.zig index 1b83e24d03..3a5357471b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,7 +11,4 @@ pub usingnamespace switch (build_config.exe_entrypoint) { .bench_codepoint_width => @import("bench/codepoint-width.zig"), .bench_grapheme_break => @import("bench/grapheme-break.zig"), .bench_page_init => @import("bench/page-init.zig"), - .bench_resize => @import("bench/resize.zig"), - .bench_screen_copy => @import("bench/screen-copy.zig"), - .bench_vt_insert_lines => @import("bench/vt-insert-lines.zig"), }; diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 497631f31a..73e771a7cb 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -309,7 +309,6 @@ test { _ = @import("segmented_pool.zig"); _ = @import("inspector/main.zig"); _ = @import("terminal/main.zig"); - _ = @import("terminal-old/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); _ = @import("unicode/main.zig"); diff --git a/src/terminal-old/Parser.zig b/src/terminal-old/Parser.zig deleted file mode 100644 index f160619e27..0000000000 --- a/src/terminal-old/Parser.zig +++ /dev/null @@ -1,794 +0,0 @@ -//! VT-series parser for escape and control sequences. -//! -//! This is implemented directly as the state machine described on -//! vt100.net: https://vt100.net/emu/dec_ansi_parser -const Parser = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const testing = std.testing; -const table = @import("parse_table.zig").table; -const osc = @import("osc.zig"); - -const log = std.log.scoped(.parser); - -/// States for the state machine -pub const State = enum { - ground, - escape, - escape_intermediate, - csi_entry, - csi_intermediate, - csi_param, - csi_ignore, - dcs_entry, - dcs_param, - dcs_intermediate, - dcs_passthrough, - dcs_ignore, - osc_string, - sos_pm_apc_string, -}; - -/// Transition action is an action that can be taken during a state -/// transition. This is more of an internal action, not one used by -/// end users, typically. -pub const TransitionAction = enum { - none, - ignore, - print, - execute, - collect, - param, - esc_dispatch, - csi_dispatch, - put, - osc_put, - apc_put, -}; - -/// Action is the action that a caller of the parser is expected to -/// take as a result of some input character. -pub const Action = union(enum) { - pub const Tag = std.meta.FieldEnum(Action); - - /// Draw character to the screen. This is a unicode codepoint. - print: u21, - - /// Execute the C0 or C1 function. - execute: u8, - - /// Execute the CSI command. Note that pointers within this - /// structure are only valid until the next call to "next". - csi_dispatch: CSI, - - /// Execute the ESC command. - esc_dispatch: ESC, - - /// Execute the OSC command. - osc_dispatch: osc.Command, - - /// DCS-related events. - dcs_hook: DCS, - dcs_put: u8, - dcs_unhook: void, - - /// APC data - apc_start: void, - apc_put: u8, - apc_end: void, - - pub const CSI = struct { - intermediates: []u8, - params: []u16, - final: u8, - sep: Sep, - - /// The separator used for CSI params. - pub const Sep = enum { semicolon, colon }; - - // Implement formatter for logging - pub fn format( - self: CSI, - comptime layout: []const u8, - opts: std.fmt.FormatOptions, - writer: anytype, - ) !void { - _ = layout; - _ = opts; - try std.fmt.format(writer, "ESC [ {s} {any} {c}", .{ - self.intermediates, - self.params, - self.final, - }); - } - }; - - pub const ESC = struct { - intermediates: []u8, - final: u8, - - // Implement formatter for logging - pub fn format( - self: ESC, - comptime layout: []const u8, - opts: std.fmt.FormatOptions, - writer: anytype, - ) !void { - _ = layout; - _ = opts; - try std.fmt.format(writer, "ESC {s} {c}", .{ - self.intermediates, - self.final, - }); - } - }; - - pub const DCS = struct { - intermediates: []const u8 = "", - params: []const u16 = &.{}, - final: u8, - }; - - // Implement formatter for logging. This is mostly copied from the - // std.fmt implementation, but we modify it slightly so that we can - // print out custom formats for some of our primitives. - pub fn format( - self: Action, - comptime layout: []const u8, - opts: std.fmt.FormatOptions, - writer: anytype, - ) !void { - _ = layout; - const T = Action; - const info = @typeInfo(T).Union; - - try writer.writeAll(@typeName(T)); - if (info.tag_type) |TagType| { - try writer.writeAll("{ ."); - try writer.writeAll(@tagName(@as(TagType, self))); - try writer.writeAll(" = "); - - inline for (info.fields) |u_field| { - // If this is the active field... - if (self == @field(TagType, u_field.name)) { - const value = @field(self, u_field.name); - switch (@TypeOf(value)) { - // Unicode - u21 => try std.fmt.format(writer, "'{u}' (U+{X})", .{ value, value }), - - // Byte - u8 => try std.fmt.format(writer, "0x{x}", .{value}), - - // Note: we don't do ASCII (u8) because there are a lot - // of invisible characters we don't want to handle right - // now. - - // All others do the default behavior - else => try std.fmt.formatType( - @field(self, u_field.name), - "any", - opts, - writer, - 3, - ), - } - } - } - - try writer.writeAll(" }"); - } else { - try format(writer, "@{x}", .{@intFromPtr(&self)}); - } - } -}; - -/// Keeps track of the parameter sep used for CSI params. We allow colons -/// to be used ONLY by the 'm' CSI action. -pub const ParamSepState = enum(u8) { - none = 0, - semicolon = ';', - colon = ':', - mixed = 1, -}; - -/// Maximum number of intermediate characters during parsing. This is -/// 4 because we also use the intermediates array for UTF8 decoding which -/// can be at most 4 bytes. -const MAX_INTERMEDIATE = 4; -const MAX_PARAMS = 16; - -/// Current state of the state machine -state: State = .ground, - -/// Intermediate tracking. -intermediates: [MAX_INTERMEDIATE]u8 = undefined, -intermediates_idx: u8 = 0, - -/// Param tracking, building -params: [MAX_PARAMS]u16 = undefined, -params_idx: u8 = 0, -params_sep: ParamSepState = .none, -param_acc: u16 = 0, -param_acc_idx: u8 = 0, - -/// Parser for OSC sequences -osc_parser: osc.Parser = .{}, - -pub fn init() Parser { - return .{}; -} - -pub fn deinit(self: *Parser) void { - self.osc_parser.deinit(); -} - -/// Next consumes the next character c and returns the actions to execute. -/// Up to 3 actions may need to be executed -- in order -- representing -/// the state exit, transition, and entry actions. -pub fn next(self: *Parser, c: u8) [3]?Action { - const effect = table[c][@intFromEnum(self.state)]; - - // log.info("next: {x}", .{c}); - - const next_state = effect.state; - const action = effect.action; - - // After generating the actions, we set our next state. - defer self.state = next_state; - - // When going from one state to another, the actions take place in this order: - // - // 1. exit action from old state - // 2. transition action - // 3. entry action to new state - return [3]?Action{ - // Exit depends on current state - if (self.state == next_state) null else switch (self.state) { - .osc_string => if (self.osc_parser.end(c)) |cmd| - Action{ .osc_dispatch = cmd } - else - null, - .dcs_passthrough => Action{ .dcs_unhook = {} }, - .sos_pm_apc_string => Action{ .apc_end = {} }, - else => null, - }, - - self.doAction(action, c), - - // Entry depends on new state - if (self.state == next_state) null else switch (next_state) { - .escape, .dcs_entry, .csi_entry => clear: { - self.clear(); - break :clear null; - }, - .osc_string => osc_string: { - self.osc_parser.reset(); - break :osc_string null; - }, - .dcs_passthrough => Action{ - .dcs_hook = .{ - .intermediates = self.intermediates[0..self.intermediates_idx], - .params = self.params[0..self.params_idx], - .final = c, - }, - }, - .sos_pm_apc_string => Action{ .apc_start = {} }, - else => null, - }, - }; -} - -pub fn collect(self: *Parser, c: u8) void { - if (self.intermediates_idx >= MAX_INTERMEDIATE) { - log.warn("invalid intermediates count", .{}); - return; - } - - self.intermediates[self.intermediates_idx] = c; - self.intermediates_idx += 1; -} - -fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { - return switch (action) { - .none, .ignore => null, - .print => Action{ .print = c }, - .execute => Action{ .execute = c }, - .collect => collect: { - self.collect(c); - break :collect null; - }, - .param => param: { - // Semicolon separates parameters. If we encounter a semicolon - // we need to store and move on to the next parameter. - if (c == ';' or c == ':') { - // Ignore too many parameters - if (self.params_idx >= MAX_PARAMS) break :param null; - - // If this is our first time seeing a parameter, we track - // the separator used so that we can't mix separators later. - if (self.params_idx == 0) self.params_sep = @enumFromInt(c); - if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed; - - // Set param final value - self.params[self.params_idx] = self.param_acc; - self.params_idx += 1; - - // Reset current param value to 0 - self.param_acc = 0; - self.param_acc_idx = 0; - break :param null; - } - - // A numeric value. Add it to our accumulator. - if (self.param_acc_idx > 0) { - self.param_acc *|= 10; - } - self.param_acc +|= c - '0'; - - // Increment our accumulator index. If we overflow then - // we're out of bounds and we exit immediately. - self.param_acc_idx, const overflow = @addWithOverflow(self.param_acc_idx, 1); - if (overflow > 0) break :param null; - - // The client is expected to perform no action. - break :param null; - }, - .osc_put => osc_put: { - self.osc_parser.next(c); - break :osc_put null; - }, - .csi_dispatch => csi_dispatch: { - // Ignore too many parameters - if (self.params_idx >= MAX_PARAMS) break :csi_dispatch null; - - // Finalize parameters if we have one - if (self.param_acc_idx > 0) { - self.params[self.params_idx] = self.param_acc; - self.params_idx += 1; - } - - const result: Action = .{ - .csi_dispatch = .{ - .intermediates = self.intermediates[0..self.intermediates_idx], - .params = self.params[0..self.params_idx], - .final = c, - .sep = switch (self.params_sep) { - .none, .semicolon => .semicolon, - .colon => .colon, - - // There is nothing that treats mixed separators specially - // afaik so we just treat it as a semicolon. - .mixed => .semicolon, - }, - }, - }; - - // We only allow colon or mixed separators for the 'm' command. - switch (self.params_sep) { - .none => {}, - .semicolon => {}, - .colon, .mixed => if (c != 'm') { - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {}", - .{result}, - ); - break :csi_dispatch null; - }, - } - - break :csi_dispatch result; - }, - .esc_dispatch => Action{ - .esc_dispatch = .{ - .intermediates = self.intermediates[0..self.intermediates_idx], - .final = c, - }, - }, - .put => Action{ .dcs_put = c }, - .apc_put => Action{ .apc_put = c }, - }; -} - -pub fn clear(self: *Parser) void { - self.intermediates_idx = 0; - self.params_idx = 0; - self.params_sep = .none; - self.param_acc = 0; - self.param_acc_idx = 0; -} - -test { - var p = init(); - _ = p.next(0x9E); - try testing.expect(p.state == .sos_pm_apc_string); - _ = p.next(0x9C); - try testing.expect(p.state == .ground); - - { - const a = p.next('a'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .print); - try testing.expect(a[2] == null); - } - - { - const a = p.next(0x19); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .execute); - try testing.expect(a[2] == null); - } -} - -test "esc: ESC ( B" { - var p = init(); - _ = p.next(0x1B); - _ = p.next('('); - - { - const a = p.next('B'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .esc_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.esc_dispatch; - try testing.expect(d.final == 'B'); - try testing.expect(d.intermediates.len == 1); - try testing.expect(d.intermediates[0] == '('); - } -} - -test "csi: ESC [ H" { - var p = init(); - _ = p.next(0x1B); - _ = p.next(0x5B); - - { - const a = p.next(0x48); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 0x48); - try testing.expect(d.params.len == 0); - } -} - -test "csi: ESC [ 1 ; 4 H" { - var p = init(); - _ = p.next(0x1B); - _ = p.next(0x5B); - _ = p.next(0x31); // 1 - _ = p.next(0x3B); // ; - _ = p.next(0x34); // 4 - - { - const a = p.next(0x48); // H - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'H'); - try testing.expect(d.params.len == 2); - try testing.expectEqual(@as(u16, 1), d.params[0]); - try testing.expectEqual(@as(u16, 4), d.params[1]); - } -} - -test "csi: SGR ESC [ 38 : 2 m" { - var p = init(); - _ = p.next(0x1B); - _ = p.next('['); - _ = p.next('3'); - _ = p.next('8'); - _ = p.next(':'); - _ = p.next('2'); - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); - try testing.expect(d.params.len == 2); - try testing.expectEqual(@as(u16, 38), d.params[0]); - try testing.expectEqual(@as(u16, 2), d.params[1]); - } -} - -test "csi: SGR colon followed by semicolon" { - var p = init(); - _ = p.next(0x1B); - for ("[48:2") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - } - - _ = p.next(0x1B); - _ = p.next('['); - { - const a = p.next('H'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - } -} - -test "csi: SGR mixed colon and semicolon" { - var p = init(); - _ = p.next(0x1B); - for ("[38:5:1;48:5:0") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - } -} - -test "csi: SGR ESC [ 48 : 2 m" { - var p = init(); - _ = p.next(0x1B); - for ("[48:2:240:143:104") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); - try testing.expect(d.params.len == 5); - try testing.expectEqual(@as(u16, 48), d.params[0]); - try testing.expectEqual(@as(u16, 2), d.params[1]); - try testing.expectEqual(@as(u16, 240), d.params[2]); - try testing.expectEqual(@as(u16, 143), d.params[3]); - try testing.expectEqual(@as(u16, 104), d.params[4]); - } -} - -test "csi: SGR ESC [4:3m colon" { - var p = init(); - _ = p.next(0x1B); - _ = p.next('['); - _ = p.next('4'); - _ = p.next(':'); - _ = p.next('3'); - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); - try testing.expect(d.params.len == 2); - try testing.expectEqual(@as(u16, 4), d.params[0]); - try testing.expectEqual(@as(u16, 3), d.params[1]); - } -} - -test "csi: SGR with many blank and colon" { - var p = init(); - _ = p.next(0x1B); - for ("[58:2::240:143:104") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('m'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); - try testing.expect(d.params.len == 6); - try testing.expectEqual(@as(u16, 58), d.params[0]); - try testing.expectEqual(@as(u16, 2), d.params[1]); - try testing.expectEqual(@as(u16, 0), d.params[2]); - try testing.expectEqual(@as(u16, 240), d.params[3]); - try testing.expectEqual(@as(u16, 143), d.params[4]); - try testing.expectEqual(@as(u16, 104), d.params[5]); - } -} - -test "csi: colon for non-m final" { - var p = init(); - _ = p.next(0x1B); - for ("[38:2h") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - try testing.expect(p.state == .ground); -} - -test "csi: request mode decrqm" { - var p = init(); - _ = p.next(0x1B); - for ("[?2026$") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('p'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'p'); - try testing.expectEqual(@as(usize, 2), d.intermediates.len); - try testing.expectEqual(@as(usize, 1), d.params.len); - try testing.expectEqual(@as(u16, '?'), d.intermediates[0]); - try testing.expectEqual(@as(u16, '$'), d.intermediates[1]); - try testing.expectEqual(@as(u16, 2026), d.params[0]); - } -} - -test "csi: change cursor" { - var p = init(); - _ = p.next(0x1B); - for ("[3 ") |c| { - const a = p.next(c); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } - - { - const a = p.next('q'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1].? == .csi_dispatch); - try testing.expect(a[2] == null); - - const d = a[1].?.csi_dispatch; - try testing.expect(d.final == 'q'); - try testing.expectEqual(@as(usize, 1), d.intermediates.len); - try testing.expectEqual(@as(usize, 1), d.params.len); - try testing.expectEqual(@as(u16, ' '), d.intermediates[0]); - try testing.expectEqual(@as(u16, 3), d.params[0]); - } -} - -test "osc: change window title" { - var p = init(); - _ = p.next(0x1B); - _ = p.next(']'); - _ = p.next('0'); - _ = p.next(';'); - _ = p.next('a'); - _ = p.next('b'); - _ = p.next('c'); - - { - const a = p.next(0x07); // BEL - try testing.expect(p.state == .ground); - try testing.expect(a[0].? == .osc_dispatch); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - - const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("abc", cmd.change_window_title); - } -} - -test "osc: change window title (end in esc)" { - var p = init(); - _ = p.next(0x1B); - _ = p.next(']'); - _ = p.next('0'); - _ = p.next(';'); - _ = p.next('a'); - _ = p.next('b'); - _ = p.next('c'); - - { - const a = p.next(0x1B); - _ = p.next('\\'); - try testing.expect(p.state == .ground); - try testing.expect(a[0].? == .osc_dispatch); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - - const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("abc", cmd.change_window_title); - } -} - -// https://github.com/darrenstarr/VtNetCore/pull/14 -// Saw this on HN, decided to add a test case because why not. -test "osc: 112 incomplete sequence" { - var p = init(); - _ = p.next(0x1B); - _ = p.next(']'); - _ = p.next('1'); - _ = p.next('1'); - _ = p.next('2'); - - { - const a = p.next(0x07); - try testing.expect(p.state == .ground); - try testing.expect(a[0].? == .osc_dispatch); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - - const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); - } -} - -test "csi: too many params" { - var p = init(); - _ = p.next(0x1B); - _ = p.next('['); - for (0..100) |_| { - _ = p.next('1'); - _ = p.next(';'); - } - _ = p.next('1'); - - { - const a = p.next('C'); - try testing.expect(p.state == .ground); - try testing.expect(a[0] == null); - try testing.expect(a[1] == null); - try testing.expect(a[2] == null); - } -} diff --git a/src/terminal-old/Screen.zig b/src/terminal-old/Screen.zig deleted file mode 100644 index 9178571280..0000000000 --- a/src/terminal-old/Screen.zig +++ /dev/null @@ -1,7925 +0,0 @@ -//! 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); -} - -// 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); -} - -// X -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); -} - -// X - selectLine in new screen -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); - } -} - -// X -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); -} - -// X - I don't think this is right -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 deleted file mode 100644 index d29513d73e..0000000000 --- a/src/terminal-old/Selection.zig +++ /dev/null @@ -1,1165 +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 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-old/StringMap.zig b/src/terminal-old/StringMap.zig deleted file mode 100644 index 588013d9db..0000000000 --- a/src/terminal-old/StringMap.zig +++ /dev/null @@ -1,124 +0,0 @@ -/// A string along with the mapping of each individual byte in the string -/// to the point in the screen. -const StringMap = @This(); - -const std = @import("std"); -const oni = @import("oniguruma"); -const point = @import("point.zig"); -const Selection = @import("Selection.zig"); -const Screen = @import("Screen.zig"); -const Allocator = std.mem.Allocator; - -string: [:0]const u8, -map: []point.ScreenPoint, - -pub fn deinit(self: StringMap, alloc: Allocator) void { - alloc.free(self.string); - alloc.free(self.map); -} - -/// Returns an iterator that yields the next match of the given regex. -pub fn searchIterator( - self: StringMap, - regex: oni.Regex, -) SearchIterator { - return .{ .map = self, .regex = regex }; -} - -/// Iterates over the regular expression matches of the string. -pub const SearchIterator = struct { - map: StringMap, - regex: oni.Regex, - offset: usize = 0, - - /// Returns the next regular expression match or null if there are - /// no more matches. - pub fn next(self: *SearchIterator) !?Match { - if (self.offset >= self.map.string.len) return null; - - var region = self.regex.search( - self.map.string[self.offset..], - .{}, - ) catch |err| switch (err) { - error.Mismatch => { - self.offset = self.map.string.len; - return null; - }, - - else => return err, - }; - errdefer region.deinit(); - - // Increment our offset by the number of bytes in the match. - // We defer this so that we can return the match before - // modifying the offset. - const end_idx: usize = @intCast(region.ends()[0]); - defer self.offset += end_idx; - - return .{ - .map = self.map, - .offset = self.offset, - .region = region, - }; - } -}; - -/// A single regular expression match. -pub const Match = struct { - map: StringMap, - offset: usize, - region: oni.Region, - - pub fn deinit(self: *Match) void { - self.region.deinit(); - } - - /// Returns the selection containing the full match. - pub fn selection(self: Match) Selection { - const start_idx: usize = @intCast(self.region.starts()[0]); - const end_idx: usize = @intCast(self.region.ends()[0] - 1); - const start_pt = self.map.map[self.offset + start_idx]; - const end_pt = self.map.map[self.offset + end_idx]; - return .{ .start = start_pt, .end = end_pt }; - } -}; - -test "searchIterator" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our regex - try oni.testing.ensureInit(); - var re = try oni.Regex.init( - "[A-B]{2}", - .{}, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - const line = s.getLine(.{ .x = 2, .y = 1 }).?; - const map = try line.stringMap(alloc); - defer map.deinit(alloc); - - // Get our iterator - var it = map.searchIterator(re); - { - var match = (try it.next()).?; - defer match.deinit(); - - const sel = match.selection(); - try testing.expectEqual(Selection{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, sel); - } - - try testing.expect(try it.next() == null); -} diff --git a/src/terminal-old/Tabstops.zig b/src/terminal-old/Tabstops.zig deleted file mode 100644 index 5a54fb28b8..0000000000 --- a/src/terminal-old/Tabstops.zig +++ /dev/null @@ -1,231 +0,0 @@ -//! Keep track of the location of tabstops. -//! -//! This is implemented as a bit set. There is a preallocation segment that -//! is used for almost all screen sizes. Then there is a dynamically allocated -//! segment if the screen is larger than the preallocation amount. -//! -//! In reality, tabstops don't need to be the most performant in any metric. -//! This implementation tries to balance denser memory usage (by using a bitset) -//! and minimizing unnecessary allocations. -const Tabstops = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const testing = std.testing; -const assert = std.debug.assert; -const fastmem = @import("../fastmem.zig"); - -/// Unit is the type we use per tabstop unit (see file docs). -const Unit = u8; -const unit_bits = @bitSizeOf(Unit); - -/// The number of columns we preallocate for. This is kind of high which -/// costs us some memory, but this is more columns than my 6k monitor at -/// 12-point font size, so this should prevent allocation in almost all -/// real world scenarios for the price of wasting at most -/// (columns / sizeOf(Unit)) bytes. -const prealloc_columns = 512; - -/// The number of entries we need for our preallocation. -const prealloc_count = prealloc_columns / unit_bits; - -/// We precompute all the possible masks since we never use a huge bit size. -const masks = blk: { - var res: [unit_bits]Unit = undefined; - for (res, 0..) |_, i| { - res[i] = @shlExact(@as(Unit, 1), @as(u3, @intCast(i))); - } - - break :blk res; -}; - -/// The number of columns this tabstop is set to manage. Use resize() -/// to change this number. -cols: usize = 0, - -/// Preallocated tab stops. -prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count, - -/// Dynamically expanded stops above prealloc stops. -dynamic_stops: []Unit = &[0]Unit{}, - -/// Returns the entry in the stops array that would contain this column. -inline fn entry(col: usize) usize { - return col / unit_bits; -} - -inline fn index(col: usize) usize { - return @mod(col, unit_bits); -} - -pub fn init(alloc: Allocator, cols: usize, interval: usize) !Tabstops { - var res: Tabstops = .{}; - try res.resize(alloc, cols); - res.reset(interval); - return res; -} - -pub fn deinit(self: *Tabstops, alloc: Allocator) void { - if (self.dynamic_stops.len > 0) alloc.free(self.dynamic_stops); - self.* = undefined; -} - -/// Set the tabstop at a certain column. The columns are 0-indexed. -pub fn set(self: *Tabstops, col: usize) void { - const i = entry(col); - const idx = index(col); - if (i < prealloc_count) { - self.prealloc_stops[i] |= masks[idx]; - return; - } - - const dynamic_i = i - prealloc_count; - assert(dynamic_i < self.dynamic_stops.len); - self.dynamic_stops[dynamic_i] |= masks[idx]; -} - -/// Unset the tabstop at a certain column. The columns are 0-indexed. -pub fn unset(self: *Tabstops, col: usize) void { - const i = entry(col); - const idx = index(col); - if (i < prealloc_count) { - self.prealloc_stops[i] ^= masks[idx]; - return; - } - - const dynamic_i = i - prealloc_count; - assert(dynamic_i < self.dynamic_stops.len); - self.dynamic_stops[dynamic_i] ^= masks[idx]; -} - -/// Get the value of a tabstop at a specific column. The columns are 0-indexed. -pub fn get(self: Tabstops, col: usize) bool { - const i = entry(col); - const idx = index(col); - const mask = masks[idx]; - const unit = if (i < prealloc_count) - self.prealloc_stops[i] - else unit: { - const dynamic_i = i - prealloc_count; - assert(dynamic_i < self.dynamic_stops.len); - break :unit self.dynamic_stops[dynamic_i]; - }; - - return unit & mask == mask; -} - -/// Resize this to support up to cols columns. -// TODO: needs interval to set new tabstops -pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void { - // Set our new value - self.cols = cols; - - // Do nothing if it fits. - if (cols <= prealloc_columns) return; - - // What we need in the dynamic size - const size = cols - prealloc_columns; - if (size < self.dynamic_stops.len) return; - - // Note: we can probably try to realloc here but I'm not sure it matters. - const new = try alloc.alloc(Unit, size); - if (self.dynamic_stops.len > 0) { - fastmem.copy(Unit, new, self.dynamic_stops); - alloc.free(self.dynamic_stops); - } - - self.dynamic_stops = new; -} - -/// Return the maximum number of columns this can support currently. -pub fn capacity(self: Tabstops) usize { - return (prealloc_count + self.dynamic_stops.len) * unit_bits; -} - -/// Unset all tabstops and then reset the initial tabstops to the given -/// interval. An interval of 0 sets no tabstops. -pub fn reset(self: *Tabstops, interval: usize) void { - @memset(&self.prealloc_stops, 0); - @memset(self.dynamic_stops, 0); - - if (interval > 0) { - var i: usize = interval; - while (i < self.cols - 1) : (i += interval) { - self.set(i); - } - } -} - -test "Tabstops: basic" { - var t: Tabstops = .{}; - defer t.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 0), entry(4)); - try testing.expectEqual(@as(usize, 1), entry(8)); - try testing.expectEqual(@as(usize, 0), index(0)); - try testing.expectEqual(@as(usize, 1), index(1)); - try testing.expectEqual(@as(usize, 1), index(9)); - - try testing.expectEqual(@as(Unit, 0b00001000), masks[3]); - try testing.expectEqual(@as(Unit, 0b00010000), masks[4]); - - try testing.expect(!t.get(4)); - t.set(4); - try testing.expect(t.get(4)); - try testing.expect(!t.get(3)); - - t.reset(0); - try testing.expect(!t.get(4)); - - t.set(4); - try testing.expect(t.get(4)); - t.unset(4); - try testing.expect(!t.get(4)); -} - -test "Tabstops: dynamic allocations" { - var t: Tabstops = .{}; - defer t.deinit(testing.allocator); - - // Grow the capacity by 2. - const cap = t.capacity(); - try t.resize(testing.allocator, cap * 2); - - // Set something that was out of range of the first - t.set(cap + 5); - try testing.expect(t.get(cap + 5)); - try testing.expect(!t.get(cap + 4)); - - // Prealloc still works - try testing.expect(!t.get(5)); -} - -test "Tabstops: interval" { - var t: Tabstops = try init(testing.allocator, 80, 4); - defer t.deinit(testing.allocator); - try testing.expect(!t.get(0)); - try testing.expect(t.get(4)); - try testing.expect(!t.get(5)); - try testing.expect(t.get(8)); -} - -test "Tabstops: count on 80" { - // https://superuser.com/questions/710019/why-there-are-11-tabstops-on-a-80-column-console - - var t: Tabstops = try init(testing.allocator, 80, 8); - defer t.deinit(testing.allocator); - - // Count the tabstops - const count: usize = count: { - var v: usize = 0; - var i: usize = 0; - while (i < 80) : (i += 1) { - if (t.get(i)) { - v += 1; - } - } - - break :count v; - }; - - try testing.expectEqual(@as(usize, 9), count); -} diff --git a/src/terminal-old/Terminal.zig b/src/terminal-old/Terminal.zig deleted file mode 100644 index 5ff2591cbe..0000000000 --- a/src/terminal-old/Terminal.zig +++ /dev/null @@ -1,7632 +0,0 @@ -//! 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(); - -const std = @import("std"); -const builtin = @import("builtin"); -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"); -const modes = @import("modes.zig"); -const charsets = @import("charsets.zig"); -const csi = @import("csi.zig"); -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 log = std.log.scoped(.terminal); - -/// Default tabstop interval -const TABSTOP_INTERVAL = 8; - -/// Screen type is an enum that tracks whether a screen is primary or alternate. -pub const ScreenType = enum { - primary, - 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. -active_screen: ScreenType, -screen: Screen, -secondary_screen: Screen, - -/// Whether we're currently writing to the status line (DECSASD and DECSSDT). -/// We don't support a status line currently so we just black hole this -/// data so that it doesn't mess up our main display. -status_display: ansi.StatusDisplay = .main, - -/// Where the tabstops are. -tabstops: Tabstops, - -/// The size of the terminal. -rows: usize, -cols: usize, - -/// The size of the screen in pixels. This is used for pty events and images -width_px: u32 = 0, -height_px: u32 = 0, - -/// The current scrolling region. -scrolling_region: ScrollingRegion, - -/// The last reported pwd, if any. -pwd: std.ArrayList(u8), - -/// The default color palette. This is only modified by changing the config file -/// and is used to reset the palette when receiving an OSC 104 command. -default_palette: color.Palette = color.default, - -/// The color palette to use. The mask indicates which palette indices have been -/// modified with OSC 4 -color_palette: struct { - const Mask = std.StaticBitSet(@typeInfo(color.Palette).Array.len); - colors: color.Palette = color.default, - mask: Mask = Mask.initEmpty(), -} = .{}, - -/// The previous printed character. This is used for the repeat previous -/// char CSI (ESC [ b). -previous_char: ?u21 = null, - -/// The modes that this terminal currently has active. -modes: modes.ModeState = .{}, - -/// The most recently set mouse shape for the terminal. -mouse_shape: mouse_shape.MouseShape = .text, - -/// These are just a packed set of flags we may set on the terminal. -flags: packed struct { - // This isn't a mode, this is set by OSC 133 using the "A" event. - // If this is true, it tells us that the shell supports redrawing - // the prompt and that when we resize, if the cursor is at a prompt, - // then we should clear the screen below and allow the shell to redraw. - shell_redraws_prompt: bool = false, - - // This is set via ESC[4;2m. Any other modify key mode just sets - // this to false and we act in mode 1 by default. - modify_other_keys_2: bool = false, - - /// The mouse event mode and format. These are set to the last - /// set mode in modes. You can't get the right event/format to use - /// based on modes alone because modes don't show you what order - /// this was called so we have to track it separately. - mouse_event: MouseEvents = .none, - mouse_format: MouseFormat = .x10, - - /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) - /// then we want to capture the shift key for the mouse protocol - /// if the configuration allows it. - mouse_shift_capture: enum { null, false, true } = .null, -} = .{}, - -/// The event types that can be reported for mouse-related activities. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseEvents = enum(u3) { - none = 0, - x10 = 1, // 9 - normal = 2, // 1000 - button = 3, // 1002 - any = 4, // 1003 - - /// Returns true if this event sends motion events. - pub fn motion(self: MouseEvents) bool { - return self == .button or self == .any; - } -}; - -/// The format of mouse events when enabled. -/// These are all mutually exclusive (hence in a single enum). -pub const MouseFormat = enum(u3) { - x10 = 0, - utf8 = 1, // 1005 - sgr = 2, // 1006 - urxvt = 3, // 1015 - sgr_pixels = 4, // 1016 -}; - -/// Scrolling region is the area of the screen designated where scrolling -/// occurs. When scrolling the screen, only this viewport is scrolled. -pub const ScrollingRegion = struct { - // Top and bottom of the scroll region (0-indexed) - // Precondition: top < bottom - top: usize, - bottom: usize, - - // Left/right scroll regions. - // Precondition: right > left - // Precondition: right <= cols - 1 - left: usize, - right: usize, -}; - -/// Initialize a new 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, rows, cols, 10000), - // No scrollback for the alternate screen - .secondary_screen = try Screen.init(alloc, rows, cols, 0), - .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), - .scrolling_region = .{ - .top = 0, - .bottom = rows - 1, - .left = 0, - .right = cols - 1, - }, - .pwd = std.ArrayList(u8).init(alloc), - }; -} - -pub fn deinit(self: *Terminal, alloc: Allocator) void { - self.tabstops.deinit(alloc); - self.screen.deinit(); - self.secondary_screen.deinit(); - self.pwd.deinit(); - 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, -}; - -/// 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); - } -} - -/// 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 }); - - // 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(alloc, .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 - if (options.cursor_save) self.restoreCursor(); -} - -/// The modes for DECCOLM. -pub const DeccolmMode = enum(u1) { - @"80_cols" = 0, - @"132_cols" = 1, -}; - -/// DECCOLM changes the terminal width between 80 and 132 columns. This -/// function call will do NOTHING unless `setDeccolmSupported` has been -/// called with "true". -/// -/// This breaks the expectation around modern terminals that they resize -/// with the window. This will fix the grid at either 80 or 132 columns. -/// The rows will continue to be variable. -pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { - // If DEC mode 40 isn't enabled, then this is ignored. We also make - // sure that we don't have deccolm set because we want to fully ignore - // set mode. - if (!self.modes.get(.enable_mode_3)) { - self.modes.set(.@"132_column", false); - return; - } - - // Enable it - self.modes.set(.@"132_column", mode == .@"132_cols"); - - // Resize to the requested size - try self.resize( - alloc, - switch (mode) { - .@"132_cols" => 132, - .@"80_cols" => 80, - }, - self.rows, - ); - - // Erase our display and move our cursor. - self.eraseDisplay(alloc, .complete, false); - self.setCursorPos(1, 1); -} - -/// Resize the underlying terminal. -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); - } - - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - self.clearPromptForResize(); - if (self.modes.get(.wraparound)) { - try self.screen.resize(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - } - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } else { - try self.screen.resizeWithoutReflow(rows, cols); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(rows, cols); - } else { - try self.secondary_screen.resizeWithoutReflow(rows, cols); - } - } - - // Set our size - self.cols = cols; - self.rows = rows; - - // Reset the scrolling region - self.scrolling_region = .{ - .top = 0, - .bottom = rows - 1, - .left = 0, - .right = cols - 1, - }; -} - -/// If shell_redraws_prompt is true and we're on the primary screen, -/// 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; -} - -/// Set the pwd for the terminal. -pub fn setPwd(self: *Terminal, pwd: []const u8) !void { - self.pwd.clearRetainingCapacity(); - try self.pwd.appendSlice(pwd); -} - -/// Returns the pwd for the terminal, if any. The memory is owned by the -/// Terminal and is not copied. It is safe until a reset or setPwd. -pub fn getPwd(self: *const Terminal) ?[]const u8 { - if (self.pwd.items.len == 0) return null; - 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. -/// -/// 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, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); -} - -/// 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; - - // 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.pen.attrs.protected = true; - self.screen.protected_mode = .iso; - }, - - .dec => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .dec; - }, - } -} - -/// Full reset -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 = .{}; - self.screen.protected_mode = .off; - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; - self.previous_char = null; - 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" { - 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(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello", str); - } -} - -// X -test "Terminal: zero-width character at start" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // This used to crash the terminal. This is not allowed so we should - // just ignore it. - try t.print(0x200D); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); -} - -// 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'); -} - -// X -test "Terminal: print over wide char at 0,0" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - try t.print(0x1F600); // Smiley face - t.setCursorPos(0, 0); - try t.print('A'); // Smiley face - - 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 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); - - try t.print('橋'); - t.setCursorPos(1, 2); - try t.print('X'); - - { - 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); - - // https://github.com/mitchellh/ghostty/issues/289 - // 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 6 cells taken up - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), 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 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 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 cell = row.getCell(3); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - } - { - 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 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); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - // https://github.com/mitchellh/ghostty/issues/289 - // 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 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, 2), 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 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 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)); - } -} - -// X -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 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 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); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - // 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 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 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); - - // 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('y'); - - // 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, 2), 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 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 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)); - } -} - -// 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" { - 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); - - // Basic grid writing - for ("hello") |c| try t.print(c); - t.setCursorPos(0, 0); - - // Make newlines so we create scrollback - // 3 pushes hello off the screen - try t.index(); - try t.index(); - try t.index(); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } - - // Scroll to the top - try t.scrollViewport(.{ .top = {} }); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello", str); - } - - // Type - try t.print('A'); - try t.scrollViewport(.{ .bottom = {} }); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA", str); - } -} - -// X -test "Terminal: print charset" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); - - // Basic grid writing - try t.print('`'); - t.configureCharset(.G0, .utf8); - try t.print('`'); - t.configureCharset(.G0, .ascii); - try t.print('`'); - t.configureCharset(.G0, .dec_special); - try t.print('`'); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("```◆", str); - } -} - -// X -test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // G1 should have no effect - t.configureCharset(.G1, .dec_special); - t.configureCharset(.G2, .dec_special); - t.configureCharset(.G3, .dec_special); - - // Basic grid writing - t.configureCharset(.G0, .dec_special); - try t.print('`'); - try t.print(0x1F600); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("◆ ", str); - } -} - -// X -test "Terminal: print invoke charset" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - t.configureCharset(.G1, .dec_special); - - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, false); - try t.print('`'); - try t.print('`'); - t.invokeCharset(.GL, .G0, false); - try t.print('`'); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆◆`", str); - } -} - -// X -test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - t.configureCharset(.G1, .dec_special); - - // Basic grid writing - try t.print('`'); - t.invokeCharset(.GL, .G1, true); - try t.print('`'); - try t.print('`'); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("`◆`", str); - } -} - -// X -test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); - - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 5); - try t.printString("XY"); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("1234X6789\n Y", str); - } -} - -// X -test "Terminal: print right margin outside" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); - - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 6); - try t.printString("XY"); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("12345XY89", str); - } -} - -// X -test "Terminal: print right margin outside wrap" { - var t = try init(testing.allocator, 10, 5); - defer t.deinit(testing.allocator); - - try t.printString("123456789"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 10); - try t.printString("XY"); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("123456789X\n Y", str); - } -} - -// X -test "Terminal: linefeed and carriage return" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("world") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), 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); - try testing.expectEqualStrings("hello\nworld", str); - } -} - -// X -test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); - try t.linefeed(); - 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); - - // Basic grid writing - t.modes.set(.linefeed, true); - try t.printString("123456"); - try t.linefeed(); - try t.print('X'); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("123456\nX", str); - } -} - -// X -test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, 5, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); - t.carriageReturn(); - 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); - - t.modes.set(.origin, true); - t.screen.cursor.x = 0; - t.scrolling_region.left = 2; - t.carriageReturn(); - 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); - - t.screen.cursor.x = 1; - t.scrolling_region.left = 2; - t.carriageReturn(); - 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); - - t.screen.cursor.x = 3; - t.scrolling_region.left = 2; - t.carriageReturn(); - 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); - - // BS - for ("hello") |c| try t.print(c); - t.backspace(); - try t.print('y'); - 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); - try testing.expectEqualStrings("helly", str); - } -} - -// X -test "Terminal: horizontal tabs" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); - - // HT - try t.print('1'); - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); - - // HT - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - - // HT at the end - try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); - try t.horizontalTab(); - 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; - try t.print('X'); - t.screen.cursor.x = 8; - try t.horizontalTab(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); - } -} - -// X -test "Terminal: horizontal tabs with right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); - - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.screen.cursor.x = 0; - try t.print('X'); - try t.horizontalTab(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X A", str); - } -} - -// 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; - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); - - // HT - try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try t.horizontalTabBack(); - 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; - try t.print('X'); - t.screen.cursor.x = 8; - try t.horizontalTabBack(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A X", str); - } -} - -// X -test "Terminal: horizontal tabs with left margin in origin mode" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); - - t.modes.set(.origin, true); - t.scrolling_region.left = 2; - t.scrolling_region.right = 5; - t.screen.cursor.x = 3; - try t.print('X'); - try t.horizontalTabBack(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" AX", str); - } -} - -// X -test "Terminal: horizontal tab back with cursor before left margin" { - const alloc = testing.allocator; - var t = try init(alloc, 20, 5); - defer t.deinit(alloc); - - t.modes.set(.origin, true); - t.saveCursor(); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(5, 0); - t.restoreCursor(); - try t.horizontalTabBack(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); - } -} - -// X -test "Terminal: cursorPos 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.setCursorPos(1, 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("XBCDE", str); - } -} - -// X -test "Terminal: cursorPos off the screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(500, 500); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n\n X", str); - } -} - -// X -test "Terminal: cursorPos relative to origin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.modes.set(.origin, true); - t.setCursorPos(1, 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: cursorPos relative to origin with left/right" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(1, 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n X", str); - } -} - -// X -test "Terminal: cursorPos limits with full scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.top = 2; - t.scrolling_region.bottom = 3; - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.modes.set(.origin, true); - t.setCursorPos(500, 500); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n X", str); - } -} - -// X -test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - - // Setting it to 0 should keep it zero (1 based) - t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - - // Should clamp to size - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - - // Should reset pending wrap - t.setCursorPos(0, 80); - try t.print('c'); - try testing.expect(t.screen.cursor.pending_wrap); - t.setCursorPos(0, 80); - try testing.expect(!t.screen.cursor.pending_wrap); - - // Origin mode - t.modes.set(.origin, true); - - // No change without a scroll region - t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - - // Set the scroll region - t.setTopAndBottomMargin(10, t.rows); - t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - - t.setCursorPos(1, 1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); - - t.setCursorPos(100, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); - - t.setTopAndBottomMargin(10, 11); - t.setCursorPos(2, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - 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); - 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(0, 0); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: setTopAndBottomMargin top only" { - 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, 0); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); - } -} - -// X -test "Terminal: setTopAndBottomMargin top and bottom" { - 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(1, 2); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nGHI", str); - } -} - -// X -test "Terminal: setTopAndBottomMargin top equal to bottom" { - 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, 2); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: setLeftAndRightMargin 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(0, 0); - t.eraseChars(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" BC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: setLeftAndRightMargin left only" { - 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 0); - 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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); - } -} - -// X -test "Terminal: setLeftAndRightMargin left and right" { - 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C\nABF\nDEI\nGH", str); - } -} - -// X -test "Terminal: setLeftAndRightMargin left equal right" { - 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.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: setLeftAndRightMargin mode 69 unset" { - 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.modes.set(.enable_left_and_right_margin, false); - t.setLeftAndRightMargin(1, 2); - t.setCursorPos(1, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: deleteLines" { - const alloc = testing.allocator; - var t = try init(alloc, 80, 80); - 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.cursorUp(2); - try t.deleteLines(1); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nE\nD", str); - } -} - -// X -test "Terminal: deleteLines with scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 80, 80); - 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - try t.deleteLines(1); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("E\nC\n\nD", str); - } -} - -// X -test "Terminal: deleteLines with scroll region, large count" { - const alloc = testing.allocator; - var t = try init(alloc, 80, 80); - 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(1, 1); - try t.deleteLines(5); - - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("E\n\n\nD", str); - } -} - -// X -test "Terminal: deleteLines with scroll region, cursor outside of region" { - const alloc = testing.allocator; - var t = try init(alloc, 80, 80); - 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.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB\nC\nD", str); - } -} - -// X -test "Terminal: deleteLines 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); - try t.deleteLines(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", str); - } -} - -// 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); - 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.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); - } -} - -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); - 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, 2); - try t.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); - } -} - -// X -test "Terminal: deleteLines left/right scroll region high count" { - 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.deleteLines(100); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); - } -} - -// X -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); - 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); - } -} - -// 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.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.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); - - 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 str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("B\nABCDE", str); - } -} - -// 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.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.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); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - try t.decaln(); - - try testing.expectEqual(@as(usize, 0), 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("EE\nEE", str); - } -} - -// X -test "Terminal: decaln reset margins" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); - - // Initial value - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try t.scrollDown(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nEEE\nEEE", str); - } -} - -// 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; - t.modes.set(.origin, true); - t.setTopAndBottomMargin(2, 3); - try t.decaln(); - try 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); - } -} - -// X -test "Terminal: insertBlanks" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - 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); - - { - 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. - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(2); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); - } -} - -// X -test "Terminal: insertBlanks more than size" { - // NOTE: this is not verified with conformance tests, so these - // tests might actually be verifying wrong behavior. - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try t.print('A'); - try t.print('B'); - try t.print('C'); - t.setCursorPos(1, 1); - t.insertBlanks(5); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// X -test "Terminal: insertBlanks no scroll region, fits" { - 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); - t.insertBlanks(2); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" ABC", str); - } -} - -// 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; - 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); - } -} - -// X -test "Terminal: insertBlanks shift off screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 10); - defer t.deinit(alloc); - - for (" ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); - } -} - -// X -test "Terminal: insertBlanks split multi-cell character" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 10); - defer t.deinit(alloc); - - for ("123") |c| try t.print(c); - try t.print('橋'); - t.setCursorPos(1, 1); - t.insertBlanks(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" 123", str); - } -} - -// X -test "Terminal: insertBlanks inside left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 3); - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 3); - t.insertBlanks(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X A", str); - } -} - -// X -test "Terminal: insertBlanks outside left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 6, 10); - defer t.deinit(alloc); - - t.setCursorPos(1, 4); - for ("ABC") |c| try t.print(c); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - t.insertBlanks(2); - 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(" ABX", str); - } -} - -// X -test "Terminal: insertBlanks left/right scroll region large count" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - t.modes.set(.origin, true); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setCursorPos(1, 1); - t.insertBlanks(140); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// X -test "Terminal: insert mode with space" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 2); - defer t.deinit(alloc); - - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hXello", str); - } -} - -// X -test "Terminal: insert mode doesn't wrap pushed characters" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hXell", str); - } -} - -// X -test "Terminal: insert mode does nothing at the end of the line" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - for ("hello") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hello\nX", str); - } -} - -// X -test "Terminal: insert mode with wide characters" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - for ("hello") |c| try t.print(c); - t.setCursorPos(1, 2); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("h😀el", str); - } -} - -// X -test "Terminal: insert mode with wide characters at end" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - for ("well") |c| try t.print(c); - t.modes.set(.insert, true); - try t.print('😀'); // 0x1F600 - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("well\n😀", str); - } -} - -// X -test "Terminal: insert mode pushing off wide character" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 2); - defer t.deinit(alloc); - - for ("123") |c| try t.print(c); - try t.print('😀'); // 0x1F600 - t.modes.set(.insert, true); - t.setCursorPos(1, 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X123", str); - } -} - -// 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); - defer t.deinit(alloc); - - 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); - { - 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); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - - try t.deleteChars(0); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE", str); - } -} - -// X -test "Terminal: deleteChars more than half" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - - try t.deleteChars(3); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AE", str); - } -} - -// X -test "Terminal: deleteChars more than line width" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - - try t.deleteChars(10); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - } -} - -// X -test "Terminal: deleteChars should shift left" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 2); - - try t.deleteChars(1); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ACDE", str); - } -} - -// X -test "Terminal: deleteChars 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); - try t.deleteChars(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: deleteChars simple operation" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.setCursorPos(1, 3); - try t.deleteChars(2); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AB23", str); - } -} - -// X -test "Terminal: deleteChars 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"); - t.setCursorPos(1, 3); - 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 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); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(2); - try testing.expect(t.screen.cursor.pending_wrap); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123", str); - } -} - -// X -test "Terminal: deleteChars inside scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 6, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.scrolling_region.left = 2; - t.scrolling_region.right = 4; - t.setCursorPos(1, 4); - try t.deleteChars(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC2 3", str); - } -} - -// X -test "Terminal: deleteChars split wide character" { - const alloc = testing.allocator; - var t = try init(alloc, 6, 10); - defer t.deinit(alloc); - - try t.printString("A橋123"); - t.setCursorPos(1, 3); - try t.deleteChars(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 123", str); - } -} - -// X -test "Terminal: deleteChars split wide character tail" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(1, t.cols - 1); - try t.print(0x6A4B); // 橋 - t.carriageReturn(); - try t.deleteChars(t.cols - 1); - try t.print('0'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("0", str); - } -} - -// 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; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.saveCursor(); - t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - 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); - - t.screen.cursor.pen.attrs.bold = true; - t.screen.cursor.x = 2; - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - 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.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; - t.screen.cursor.pen.attrs.bold = false; - t.modes.set(.origin, false); - t.primaryScreen(alloc, .{ - .cursor_save = true, - .clear_on_enter = true, - }); - 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); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - t.restoreCursor(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("B AX", str); - } -} - -// X -test "Terminal: saveCursor pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.saveCursor(); - t.setCursorPos(1, 1); - try t.print('B'); - t.restoreCursor(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("B A\nX", str); - } -} - -// X -test "Terminal: saveCursor origin mode" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - t.modes.set(.origin, true); - t.saveCursor(); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(3, 5); - t.setTopAndBottomMargin(2, 4); - t.restoreCursor(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X", str); - } -} - -// X -test "Terminal: saveCursor resize" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - t.setCursorPos(1, 10); - t.saveCursor(); - try t.resize(alloc, 5, 5); - t.restoreCursor(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// 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); - 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); -} - -// 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); - t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.pen.attrs.protected); - t.setProtectedMode(.off); - 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); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); - } -} - -// X -test "Terminal: eraseLine 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.eraseLine(.right, false); - 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("ABCDB", str); - } -} - -// X -test "Terminal: eraseLine 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.eraseLine(.right, false); - - { - 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("X\n123", str); - } -} - -// 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; - t.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - 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); - } - } -} - -// X -test "Terminal: eraseLine right wide character" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 4); - t.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AB", str); - } -} - -// X -test "Terminal: eraseLine right 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.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); - } -} - -// X -test "Terminal: eraseLine right 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, 2); - t.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - } -} - -// X -test "Terminal: eraseLine right 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, 2); - t.eraseLine(.right, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - } -} - -// X -test "Terminal: eraseLine right protected requested" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - for ("12345678") |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.eraseLine(.right, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("123 X", str); - } -} - -// X -test "Terminal: eraseLine simple erase left" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); - } -} - -// X -test "Terminal: eraseLine left 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.eraseLine(.left, false); - 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", str); - } -} - -// 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; - t.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - 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); - } - } -} - -// X -test "Terminal: eraseLine left wide character" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - for ("AB") |c| try t.print(c); - try t.print('橋'); - for ("DE") |c| try t.print(c); - t.setCursorPos(1, 3); - t.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" DE", str); - } -} - -// X -test "Terminal: eraseLine left 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.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); - } -} - -// X -test "Terminal: eraseLine left 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, 2); - t.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - } -} - -// X -test "Terminal: eraseLine left 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, 2); - t.eraseLine(.left, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - } -} - -// X -test "Terminal: eraseLine left protected requested" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - 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.eraseLine(.left, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X 9", str); - } -} - -// 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; - t.eraseLine(.complete, false); - - { - const str = try t.plainString(testing.allocator); - 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); - } - } -} - -// X -test "Terminal: eraseLine complete 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.eraseLine(.complete, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); - } -} - -// X -test "Terminal: eraseLine complete 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, 2); - t.eraseLine(.complete, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// X -test "Terminal: eraseLine complete 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, 2); - t.eraseLine(.complete, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// X -test "Terminal: eraseLine complete protected requested" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - 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.eraseLine(.complete, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// 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); - defer t.deinit(alloc); - - try t.horizontalTab(); - t.tabClear(.current); - t.setCursorPos(1, 1); - try t.horizontalTab(); - 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); - defer t.deinit(alloc); - - t.tabClear(.all); - t.setCursorPos(1, 1); - try t.horizontalTab(); - 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); - defer t.deinit(alloc); - - try t.printString("A"); - try t.printRepeat(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AA", str); - } -} - -// X -test "Terminal: printRepeat wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString(" A"); - try t.printRepeat(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A\nA", str); - } -} - -// X -test "Terminal: printRepeat no previous character" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printRepeat(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// 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); - defer t.deinit(alloc); - - var storage: [64]u8 = undefined; - - { - try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;38:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); - } - - { - try t.setAttribute(.bold); - try t.setAttribute(.faint); - try t.setAttribute(.italic); - try t.setAttribute(.{ .underline = .single }); - try t.setAttribute(.blink); - try t.setAttribute(.inverse); - try t.setAttribute(.invisible); - try t.setAttribute(.strikethrough); - try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); - } - - { - try t.setAttribute(.{ .underline = .single }); - defer t.setAttribute(.unset) catch unreachable; - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;4", buf); - } - - { - const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0", buf); - } -} - -test "Terminal: preserve grapheme cluster on large scrollback" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - // 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.screen.testString(alloc, .viewport); - defer testing.allocator.free(str); - try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); - } -} diff --git a/src/terminal-old/UTF8Decoder.zig b/src/terminal-old/UTF8Decoder.zig deleted file mode 100644 index 6bb0d98159..0000000000 --- a/src/terminal-old/UTF8Decoder.zig +++ /dev/null @@ -1,142 +0,0 @@ -//! DFA-based non-allocating error-replacing UTF-8 decoder. -//! -//! This implementation is based largely on the excellent work of -//! Bjoern Hoehrmann, with slight modifications to support error- -//! replacement. -//! -//! For details on Bjoern's DFA-based UTF-8 decoder, see -//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed) -const UTF8Decoder = @This(); - -const std = @import("std"); -const testing = std.testing; - -const log = std.log.scoped(.utf8decoder); - -// zig fmt: off -const char_classes = [_]u4{ - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, - 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, - 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, - 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, -}; - -const transitions = [_]u8 { - 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, - 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, - 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, - 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, - 12,36,12,12,12,12,12,12,12,12,12,12, -}; -// zig fmt: on - -// DFA states -const ACCEPT_STATE = 0; -const REJECT_STATE = 12; - -// This is where we accumulate our current codepoint. -accumulator: u21 = 0, -// The internal state of the DFA. -state: u8 = ACCEPT_STATE, - -/// Takes the next byte in the utf-8 sequence and emits a tuple of -/// - The codepoint that was generated, if there is one. -/// - A boolean that indicates whether the provided byte was consumed. -/// -/// The only case where the byte is not consumed is if an ill-formed -/// sequence is reached, in which case a replacement character will be -/// emitted and the byte will not be consumed. -/// -/// If the byte is not consumed, the caller is responsible for calling -/// again with the same byte before continuing. -pub inline fn next(self: *UTF8Decoder, byte: u8) struct { ?u21, bool } { - const char_class = char_classes[byte]; - - const initial_state = self.state; - - if (self.state != ACCEPT_STATE) { - self.accumulator <<= 6; - self.accumulator |= (byte & 0x3F); - } else { - self.accumulator = (@as(u21, 0xFF) >> char_class) & (byte); - } - - self.state = transitions[self.state + char_class]; - - if (self.state == ACCEPT_STATE) { - defer self.accumulator = 0; - - // Emit the fully decoded codepoint. - return .{ self.accumulator, true }; - } else if (self.state == REJECT_STATE) { - self.accumulator = 0; - self.state = ACCEPT_STATE; - // Emit a replacement character. If we rejected the first byte - // in a sequence, then it was consumed, otherwise it was not. - return .{ 0xFFFD, initial_state == ACCEPT_STATE }; - } else { - // Emit nothing, we're in the middle of a sequence. - return .{ null, true }; - } -} - -test "ASCII" { - var d: UTF8Decoder = .{}; - var out: [13]u8 = undefined; - for ("Hello, World!", 0..) |byte, i| { - const res = d.next(byte); - try testing.expect(res[1]); - if (res[0]) |codepoint| { - out[i] = @intCast(codepoint); - } - } - - try testing.expect(std.mem.eql(u8, &out, "Hello, World!")); -} - -test "Well formed utf-8" { - var d: UTF8Decoder = .{}; - var out: [4]u21 = undefined; - var i: usize = 0; - // 4 bytes, 3 bytes, 2 bytes, 1 byte - for ("😄✤ÁA") |byte| { - var consumed = false; - while (!consumed) { - const res = d.next(byte); - consumed = res[1]; - // There are no errors in this sequence, so - // every byte should be consumed first try. - try testing.expect(consumed == true); - if (res[0]) |codepoint| { - out[i] = codepoint; - i += 1; - } - } - } - - try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0x1F604, 0x2724, 0xC1, 0x41 })); -} - -test "Partially invalid utf-8" { - var d: UTF8Decoder = .{}; - var out: [5]u21 = undefined; - var i: usize = 0; - // Illegally terminated sequence, valid sequence, illegal surrogate pair. - for ("\xF0\x9F😄\xED\xA0\x80") |byte| { - var consumed = false; - while (!consumed) { - const res = d.next(byte); - consumed = res[1]; - if (res[0]) |codepoint| { - out[i] = codepoint; - i += 1; - } - } - } - - try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD })); -} diff --git a/src/terminal-old/ansi.zig b/src/terminal-old/ansi.zig deleted file mode 100644 index 43c2a9a1c2..0000000000 --- a/src/terminal-old/ansi.zig +++ /dev/null @@ -1,114 +0,0 @@ -/// C0 (7-bit) control characters from ANSI. -/// -/// This is not complete, control characters are only added to this -/// as the terminal emulator handles them. -pub const C0 = enum(u7) { - /// Null - NUL = 0x00, - /// Start of heading - SOH = 0x01, - /// Start of text - STX = 0x02, - /// Enquiry - ENQ = 0x05, - /// Bell - BEL = 0x07, - /// Backspace - BS = 0x08, - // Horizontal tab - HT = 0x09, - /// Line feed - LF = 0x0A, - /// Vertical Tab - VT = 0x0B, - /// Form feed - FF = 0x0C, - /// Carriage return - CR = 0x0D, - /// Shift out - SO = 0x0E, - /// Shift in - SI = 0x0F, - - // Non-exhaustive so that @intToEnum never fails since the inputs are - // user-generated. - _, -}; - -/// The SGR rendition aspects that can be set, sometimes known as attributes. -/// The value corresponds to the parameter value for the SGR command (ESC [ m). -pub const RenditionAspect = enum(u16) { - default = 0, - bold = 1, - default_fg = 39, - default_bg = 49, - - // Non-exhaustive so that @intToEnum never fails since the inputs are - // user-generated. - _, -}; - -/// The device attribute request type (ESC [ c). -pub const DeviceAttributeReq = enum { - primary, // Blank - secondary, // > - tertiary, // = -}; - -/// Possible cursor styles (ESC [ q) -pub const CursorStyle = enum(u16) { - default = 0, - blinking_block = 1, - steady_block = 2, - blinking_underline = 3, - steady_underline = 4, - blinking_bar = 5, - steady_bar = 6, - - // Non-exhaustive so that @intToEnum never fails for unsupported modes. - _, - - /// True if the cursor should blink. - pub fn blinking(self: CursorStyle) bool { - return switch (self) { - .blinking_block, .blinking_underline, .blinking_bar => true, - else => false, - }; - } -}; - -/// The status line type for DECSSDT. -pub const StatusLineType = enum(u16) { - none = 0, - indicator = 1, - host_writable = 2, - - // Non-exhaustive so that @intToEnum never fails for unsupported values. - _, -}; - -/// The display to target for status updates (DECSASD). -pub const StatusDisplay = enum(u16) { - main = 0, - status_line = 1, - - // Non-exhaustive so that @intToEnum never fails for unsupported values. - _, -}; - -/// The possible modify key formats to ESC[>{a};{b}m -/// Note: this is not complete, we should add more as we support more -pub const ModifyKeyFormat = union(enum) { - legacy: void, - cursor_keys: void, - function_keys: void, - other_keys: enum { none, numeric_except, numeric }, -}; - -/// The protection modes that can be set for the terminal. See DECSCA and -/// ESC V, W. -pub const ProtectedMode = enum { - off, - iso, // ESC V, W - dec, // CSI Ps " q -}; diff --git a/src/terminal-old/apc.zig b/src/terminal-old/apc.zig deleted file mode 100644 index 6a6b8cc360..0000000000 --- a/src/terminal-old/apc.zig +++ /dev/null @@ -1,137 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const kitty_gfx = @import("kitty/graphics.zig"); - -const log = std.log.scoped(.terminal_apc); - -/// APC command handler. This should be hooked into a terminal.Stream handler. -/// The start/feed/end functions are meant to be called from the terminal.Stream -/// apcStart, apcPut, and apcEnd functions, respectively. -pub const Handler = struct { - state: State = .{ .inactive = {} }, - - pub fn deinit(self: *Handler) void { - self.state.deinit(); - } - - pub fn start(self: *Handler) void { - self.state.deinit(); - self.state = .{ .identify = {} }; - } - - pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { - switch (self.state) { - .inactive => unreachable, - - // We're ignoring this APC command, likely because we don't - // recognize it so there is no need to store the data in memory. - .ignore => return, - - // We identify the APC command by the first byte. - .identify => { - switch (byte) { - // Kitty graphics protocol - 'G' => self.state = .{ .kitty = kitty_gfx.CommandParser.init(alloc) }, - - // Unknown - else => self.state = .{ .ignore = {} }, - } - }, - - .kitty => |*p| p.feed(byte) catch |err| { - log.warn("kitty graphics protocol error: {}", .{err}); - self.state = .{ .ignore = {} }; - }, - } - } - - pub fn end(self: *Handler) ?Command { - defer { - self.state.deinit(); - self.state = .{ .inactive = {} }; - } - - return switch (self.state) { - .inactive => unreachable, - .ignore, .identify => null, - .kitty => |*p| kitty: { - const command = p.complete() catch |err| { - log.warn("kitty graphics protocol error: {}", .{err}); - break :kitty null; - }; - - break :kitty .{ .kitty = command }; - }, - }; - } -}; - -pub const State = union(enum) { - /// We're not in the middle of an APC command yet. - inactive: void, - - /// We got an unrecognized APC sequence or the APC sequence we - /// recognized became invalid. We're just dropping bytes. - ignore: void, - - /// We're waiting to identify the APC sequence. This is done by - /// inspecting the first byte of the sequence. - identify: void, - - /// Kitty graphics protocol - kitty: kitty_gfx.CommandParser, - - pub fn deinit(self: *State) void { - switch (self.*) { - .inactive, .ignore, .identify => {}, - .kitty => |*v| v.deinit(), - } - } -}; - -/// Possible APC commands. -pub const Command = union(enum) { - kitty: kitty_gfx.Command, - - pub fn deinit(self: *Command, alloc: Allocator) void { - switch (self.*) { - .kitty => |*v| v.deinit(alloc), - } - } -}; - -test "unknown APC command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - h.start(); - for ("Xabcdef1234") |c| h.feed(alloc, c); - try testing.expect(h.end() == null); -} - -test "garbage Kitty command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - h.start(); - for ("Gabcdef1234") |c| h.feed(alloc, c); - try testing.expect(h.end() == null); -} - -test "valid Kitty command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - h.start(); - const input = "Gf=24,s=10,v=20,hello=world"; - for (input) |c| h.feed(alloc, c); - - var cmd = h.end().?; - defer cmd.deinit(alloc); - try testing.expect(cmd == .kitty); -} diff --git a/src/terminal-old/charsets.zig b/src/terminal-old/charsets.zig deleted file mode 100644 index 3162384581..0000000000 --- a/src/terminal-old/charsets.zig +++ /dev/null @@ -1,114 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -/// The available charset slots for a terminal. -pub const Slots = enum(u3) { - G0 = 0, - G1 = 1, - G2 = 2, - G3 = 3, -}; - -/// The name of the active slots. -pub const ActiveSlot = enum { GL, GR }; - -/// The list of supported character sets and their associated tables. -pub const Charset = enum { - utf8, - ascii, - british, - dec_special, - - /// The table for the given charset. This returns a pointer to a - /// slice that is guaranteed to be 255 chars that can be used to map - /// ASCII to the given charset. - pub fn table(set: Charset) []const u16 { - return switch (set) { - .british => &british, - .dec_special => &dec_special, - - // utf8 is not a table, callers should double-check if the - // charset is utf8 and NOT use tables. - .utf8 => unreachable, - - // recommended that callers just map ascii directly but we can - // support a table - .ascii => &ascii, - }; - } -}; - -/// Just a basic c => c ascii table -const ascii = initTable(); - -/// https://vt100.net/docs/vt220-rm/chapter2.html -const british = british: { - var table = initTable(); - table[0x23] = 0x00a3; - break :british table; -}; - -/// https://en.wikipedia.org/wiki/DEC_Special_Graphics -const dec_special = tech: { - var table = initTable(); - table[0x60] = 0x25C6; - table[0x61] = 0x2592; - table[0x62] = 0x2409; - table[0x63] = 0x240C; - table[0x64] = 0x240D; - table[0x65] = 0x240A; - table[0x66] = 0x00B0; - table[0x67] = 0x00B1; - table[0x68] = 0x2424; - table[0x69] = 0x240B; - table[0x6a] = 0x2518; - table[0x6b] = 0x2510; - table[0x6c] = 0x250C; - table[0x6d] = 0x2514; - table[0x6e] = 0x253C; - table[0x6f] = 0x23BA; - table[0x70] = 0x23BB; - table[0x71] = 0x2500; - table[0x72] = 0x23BC; - table[0x73] = 0x23BD; - table[0x74] = 0x251C; - table[0x75] = 0x2524; - table[0x76] = 0x2534; - table[0x77] = 0x252C; - table[0x78] = 0x2502; - table[0x79] = 0x2264; - table[0x7a] = 0x2265; - table[0x7b] = 0x03C0; - table[0x7c] = 0x2260; - table[0x7d] = 0x00A3; - table[0x7e] = 0x00B7; - break :tech table; -}; - -/// Our table length is 256 so we can contain all ASCII chars. -const table_len = std.math.maxInt(u8) + 1; - -/// Creates a table that maps ASCII to ASCII as a getting started point. -fn initTable() [table_len]u16 { - var result: [table_len]u16 = undefined; - var i: usize = 0; - while (i < table_len) : (i += 1) result[i] = @intCast(i); - assert(i == table_len); - return result; -} - -test { - const testing = std.testing; - const info = @typeInfo(Charset).Enum; - inline for (info.fields) |field| { - // utf8 has no table - if (@field(Charset, field.name) == .utf8) continue; - - const table = @field(Charset, field.name).table(); - - // Yes, I could use `table_len` here, but I want to explicitly use a - // hardcoded constant so that if there are miscompilations or a comptime - // issue, we catch it. - try testing.expectEqual(@as(usize, 256), table.len); - } -} diff --git a/src/terminal-old/color.zig b/src/terminal-old/color.zig deleted file mode 100644 index 194cee8b14..0000000000 --- a/src/terminal-old/color.zig +++ /dev/null @@ -1,339 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const x11_color = @import("x11_color.zig"); - -/// The default palette. -pub const default: Palette = default: { - var result: Palette = undefined; - - // Named values - var i: u8 = 0; - while (i < 16) : (i += 1) { - result[i] = Name.default(@enumFromInt(i)) catch unreachable; - } - - // Cube - assert(i == 16); - var r: u8 = 0; - while (r < 6) : (r += 1) { - var g: u8 = 0; - while (g < 6) : (g += 1) { - var b: u8 = 0; - while (b < 6) : (b += 1) { - result[i] = .{ - .r = if (r == 0) 0 else (r * 40 + 55), - .g = if (g == 0) 0 else (g * 40 + 55), - .b = if (b == 0) 0 else (b * 40 + 55), - }; - - i += 1; - } - } - } - - // Grey ramp - assert(i == 232); - assert(@TypeOf(i) == u8); - while (i > 0) : (i +%= 1) { - const value = ((i - 232) * 10) + 8; - result[i] = .{ .r = value, .g = value, .b = value }; - } - - break :default result; -}; - -/// Palette is the 256 color palette. -pub const Palette = [256]RGB; - -/// Color names in the standard 8 or 16 color palette. -pub const Name = enum(u8) { - black = 0, - red = 1, - green = 2, - yellow = 3, - blue = 4, - magenta = 5, - cyan = 6, - white = 7, - - bright_black = 8, - bright_red = 9, - bright_green = 10, - bright_yellow = 11, - bright_blue = 12, - bright_magenta = 13, - bright_cyan = 14, - bright_white = 15, - - // Remainders are valid unnamed values in the 256 color palette. - _, - - /// Default colors for tagged values. - pub fn default(self: Name) !RGB { - return switch (self) { - .black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, - .red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 }, - .green => RGB{ .r = 0xB5, .g = 0xBD, .b = 0x68 }, - .yellow => RGB{ .r = 0xF0, .g = 0xC6, .b = 0x74 }, - .blue => RGB{ .r = 0x81, .g = 0xA2, .b = 0xBE }, - .magenta => RGB{ .r = 0xB2, .g = 0x94, .b = 0xBB }, - .cyan => RGB{ .r = 0x8A, .g = 0xBE, .b = 0xB7 }, - .white => RGB{ .r = 0xC5, .g = 0xC8, .b = 0xC6 }, - - .bright_black => RGB{ .r = 0x66, .g = 0x66, .b = 0x66 }, - .bright_red => RGB{ .r = 0xD5, .g = 0x4E, .b = 0x53 }, - .bright_green => RGB{ .r = 0xB9, .g = 0xCA, .b = 0x4A }, - .bright_yellow => RGB{ .r = 0xE7, .g = 0xC5, .b = 0x47 }, - .bright_blue => RGB{ .r = 0x7A, .g = 0xA6, .b = 0xDA }, - .bright_magenta => RGB{ .r = 0xC3, .g = 0x97, .b = 0xD8 }, - .bright_cyan => RGB{ .r = 0x70, .g = 0xC0, .b = 0xB1 }, - .bright_white => RGB{ .r = 0xEA, .g = 0xEA, .b = 0xEA }, - - else => error.NoDefaultValue, - }; - } -}; - -/// RGB -pub const RGB = struct { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, - - pub fn eql(self: RGB, other: RGB) bool { - return self.r == other.r and self.g == other.g and self.b == other.b; - } - - /// Calculates the contrast ratio between two colors. The contrast - /// ration is a value between 1 and 21 where 1 is the lowest contrast - /// and 21 is the highest contrast. - /// - /// https://www.w3.org/TR/WCAG20/#contrast-ratiodef - pub fn contrast(self: RGB, other: RGB) f64 { - // pair[0] = lighter, pair[1] = darker - const pair: [2]f64 = pair: { - const self_lum = self.luminance(); - const other_lum = other.luminance(); - if (self_lum > other_lum) break :pair .{ self_lum, other_lum }; - break :pair .{ other_lum, self_lum }; - }; - - return (pair[0] + 0.05) / (pair[1] + 0.05); - } - - /// Calculates luminance based on the W3C formula. This returns a - /// normalized value between 0 and 1 where 0 is black and 1 is white. - /// - /// https://www.w3.org/TR/WCAG20/#relativeluminancedef - pub fn luminance(self: RGB) f64 { - const r_lum = componentLuminance(self.r); - const g_lum = componentLuminance(self.g); - const b_lum = componentLuminance(self.b); - return 0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum; - } - - /// Calculates single-component luminance based on the W3C formula. - /// - /// Expects sRGB color space which at the time of writing we don't - /// generally use but it's a good enough approximation until we fix that. - /// https://www.w3.org/TR/WCAG20/#relativeluminancedef - fn componentLuminance(c: u8) f64 { - const c_f64: f64 = @floatFromInt(c); - const normalized: f64 = c_f64 / 255; - if (normalized <= 0.03928) return normalized / 12.92; - return std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); - } - - /// Calculates "perceived luminance" which is better for determining - /// light vs dark. - /// - /// Source: https://www.w3.org/TR/AERT/#color-contrast - pub fn perceivedLuminance(self: RGB) f64 { - const r_f64: f64 = @floatFromInt(self.r); - const g_f64: f64 = @floatFromInt(self.g); - const b_f64: f64 = @floatFromInt(self.b); - return 0.299 * (r_f64 / 255) + 0.587 * (g_f64 / 255) + 0.114 * (b_f64 / 255); - } - - test "size" { - try std.testing.expectEqual(@as(usize, 24), @bitSizeOf(RGB)); - try std.testing.expectEqual(@as(usize, 3), @sizeOf(RGB)); - } - - /// Parse a color from a floating point intensity value. - /// - /// The value should be between 0.0 and 1.0, inclusive. - fn fromIntensity(value: []const u8) !u8 { - const i = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat; - if (i < 0.0 or i > 1.0) { - return error.InvalidFormat; - } - - return @intFromFloat(i * std.math.maxInt(u8)); - } - - /// Parse a color from a string of hexadecimal digits - /// - /// The string can contain 1, 2, 3, or 4 characters and represents the color - /// value scaled in 4, 8, 12, or 16 bits, respectively. - fn fromHex(value: []const u8) !u8 { - if (value.len == 0 or value.len > 4) { - return error.InvalidFormat; - } - - const color = std.fmt.parseUnsigned(u16, value, 16) catch return error.InvalidFormat; - const divisor: usize = switch (value.len) { - 1 => std.math.maxInt(u4), - 2 => std.math.maxInt(u8), - 3 => std.math.maxInt(u12), - 4 => std.math.maxInt(u16), - else => unreachable, - }; - - return @intCast(@as(usize, color) * std.math.maxInt(u8) / divisor); - } - - /// Parse a color specification. - /// - /// Any of the following forms are accepted: - /// - /// 1. rgb:// - /// - /// , , := h | hh | hhh | hhhh - /// - /// where `h` is a single hexadecimal digit. - /// - /// 2. rgbi:// - /// - /// where , , and are floating point values between - /// 0.0 and 1.0 (inclusive). - /// - /// 3. #hhhhhh - /// - /// where `h` is a single hexadecimal digit. - pub fn parse(value: []const u8) !RGB { - if (value.len == 0) { - return error.InvalidFormat; - } - - if (value[0] == '#') { - if (value.len != 7) { - return error.InvalidFormat; - } - - return RGB{ - .r = try RGB.fromHex(value[1..3]), - .g = try RGB.fromHex(value[3..5]), - .b = try RGB.fromHex(value[5..7]), - }; - } - - // Check for X11 named colors. We allow whitespace around the edges - // of the color because Kitty allows whitespace. This is not part of - // any spec I could find. - if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; - - if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { - return error.InvalidFormat; - } - - var i: usize = 3; - - const use_intensity = if (value[i] == 'i') blk: { - i += 1; - break :blk true; - } else false; - - if (value[i] != ':') { - return error.InvalidFormat; - } - - i += 1; - - const r = r: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] - else - return error.InvalidFormat; - - i += slice.len + 1; - - break :r if (use_intensity) - try RGB.fromIntensity(slice) - else - try RGB.fromHex(slice); - }; - - const g = g: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] - else - return error.InvalidFormat; - - i += slice.len + 1; - - break :g if (use_intensity) - try RGB.fromIntensity(slice) - else - try RGB.fromHex(slice); - }; - - const b = if (use_intensity) - try RGB.fromIntensity(value[i..]) - else - try RGB.fromHex(value[i..]); - - return RGB{ - .r = r, - .g = g, - .b = b, - }; - } -}; - -test "palette: default" { - const testing = std.testing; - - // Safety check - var i: u8 = 0; - while (i < 16) : (i += 1) { - try testing.expectEqual(Name.default(@as(Name, @enumFromInt(i))), default[i]); - } -} - -test "RGB.parse" { - const testing = std.testing; - - try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("rgbi:1.0/0/0")); - try testing.expectEqual(RGB{ .r = 127, .g = 160, .b = 0 }, try RGB.parse("rgb:7f/a0a0/0")); - try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("rgb:f/ff/fff")); - try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffff")); - try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010")); - - try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black")); - try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red")); - try testing.expectEqual(RGB{ .r = 0, .g = 255, .b = 0 }, try RGB.parse("green")); - try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 255 }, try RGB.parse("blue")); - try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("white")); - - try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen")); - try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green")); - try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green ")); - - // Invalid format - try testing.expectError(error.InvalidFormat, RGB.parse("rgb;")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:")); - try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a")); - try testing.expectError(error.InvalidFormat, RGB.parse("a/a/a")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:a/a/a/")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:00000///")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:000/")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgbi:a/a/a")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:0.5/0.0/1.0")); - try testing.expectError(error.InvalidFormat, RGB.parse("rgb:not/hex/zz")); - try testing.expectError(error.InvalidFormat, RGB.parse("#")); - try testing.expectError(error.InvalidFormat, RGB.parse("#ff")); - try testing.expectError(error.InvalidFormat, RGB.parse("#ffff")); - try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); - try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); -} diff --git a/src/terminal-old/csi.zig b/src/terminal-old/csi.zig deleted file mode 100644 index 877f5986e8..0000000000 --- a/src/terminal-old/csi.zig +++ /dev/null @@ -1,33 +0,0 @@ -// Modes for the ED CSI command. -pub const EraseDisplay = enum(u8) { - below = 0, - above = 1, - complete = 2, - scrollback = 3, - - /// This is an extension added by Kitty to move the viewport into the - /// scrollback and then erase the display. - scroll_complete = 22, -}; - -// Modes for the EL CSI command. -pub const EraseLine = enum(u8) { - right = 0, - left = 1, - complete = 2, - right_unless_pending_wrap = 4, - - // Non-exhaustive so that @intToEnum never fails since the inputs are - // user-generated. - _, -}; - -// Modes for the TBC (tab clear) command. -pub const TabClear = enum(u8) { - current = 0, - all = 3, - - // Non-exhaustive so that @intToEnum never fails since the inputs are - // user-generated. - _, -}; diff --git a/src/terminal-old/dcs.zig b/src/terminal-old/dcs.zig deleted file mode 100644 index cde00d2188..0000000000 --- a/src/terminal-old/dcs.zig +++ /dev/null @@ -1,309 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const terminal = @import("main.zig"); -const DCS = terminal.DCS; - -const log = std.log.scoped(.terminal_dcs); - -/// DCS command handler. This should be hooked into a terminal.Stream handler. -/// The hook/put/unhook functions are meant to be called from the -/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively. -pub const Handler = struct { - state: State = .{ .inactive = {} }, - - /// Maximum bytes any DCS command can take. This is to prevent - /// malicious input from causing us to allocate too much memory. - /// This is arbitrarily set to 1MB today, increase if needed. - max_bytes: usize = 1024 * 1024, - - pub fn deinit(self: *Handler) void { - self.discard(); - } - - pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void { - assert(self.state == .inactive); - self.state = if (tryHook(alloc, dcs)) |state_| state: { - if (state_) |state| break :state state else { - log.info("unknown DCS hook: {}", .{dcs}); - break :state .{ .ignore = {} }; - } - } else |err| state: { - log.info( - "error initializing DCS hook, will ignore hook err={}", - .{err}, - ); - break :state .{ .ignore = {} }; - }; - } - - fn tryHook(alloc: Allocator, dcs: DCS) !?State { - return switch (dcs.intermediates.len) { - 1 => switch (dcs.intermediates[0]) { - '+' => switch (dcs.final) { - // XTGETTCAP - // https://github.com/mitchellh/ghostty/issues/517 - 'q' => .{ - .xtgettcap = try std.ArrayList(u8).initCapacity( - alloc, - 128, // Arbitrary choice - ), - }, - - else => null, - }, - - '$' => switch (dcs.final) { - // DECRQSS - 'q' => .{ - .decrqss = .{}, - }, - - else => null, - }, - - else => null, - }, - - else => null, - }; - } - - pub fn put(self: *Handler, byte: u8) void { - self.tryPut(byte) catch |err| { - // On error we just discard our state and ignore the rest - log.info("error putting byte into DCS handler err={}", .{err}); - self.discard(); - self.state = .{ .ignore = {} }; - }; - } - - fn tryPut(self: *Handler, byte: u8) !void { - switch (self.state) { - .inactive, - .ignore, - => {}, - - .xtgettcap => |*list| { - if (list.items.len >= self.max_bytes) { - return error.OutOfMemory; - } - - try list.append(byte); - }, - - .decrqss => |*buffer| { - if (buffer.len >= buffer.data.len) { - return error.OutOfMemory; - } - - buffer.data[buffer.len] = byte; - buffer.len += 1; - }, - } - } - - pub fn unhook(self: *Handler) ?Command { - defer self.state = .{ .inactive = {} }; - return switch (self.state) { - .inactive, - .ignore, - => null, - - .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, - - .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { - 0 => .none, - 1 => switch (buffer.data[0]) { - 'm' => .sgr, - 'r' => .decstbm, - 's' => .decslrm, - else => .none, - }, - 2 => switch (buffer.data[0]) { - ' ' => switch (buffer.data[1]) { - 'q' => .decscusr, - else => .none, - }, - else => .none, - }, - else => unreachable, - } }, - }; - } - - fn discard(self: *Handler) void { - switch (self.state) { - .inactive, - .ignore, - => {}, - - .xtgettcap => |*list| list.deinit(), - - .decrqss => {}, - } - - self.state = .{ .inactive = {} }; - } -}; - -pub const Command = union(enum) { - /// XTGETTCAP - xtgettcap: XTGETTCAP, - - /// DECRQSS - decrqss: DECRQSS, - - pub fn deinit(self: Command) void { - switch (self) { - .xtgettcap => |*v| { - v.data.deinit(); - }, - .decrqss => {}, - } - } - - pub const XTGETTCAP = struct { - data: std.ArrayList(u8), - i: usize = 0, - - /// Returns the next terminfo key being requested and null - /// when there are no more keys. The returned value is NOT hex-decoded - /// because we expect to use a comptime lookup table. - pub fn next(self: *XTGETTCAP) ?[]const u8 { - if (self.i >= self.data.items.len) return null; - - var rem = self.data.items[self.i..]; - const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len; - - // Note that if we're at the end, idx + 1 is len + 1 so we're over - // the end but that's okay because our check above is >= so we'll - // never read. - self.i += idx + 1; - - return rem[0..idx]; - } - }; - - /// Supported DECRQSS settings - pub const DECRQSS = enum { - none, - sgr, - decscusr, - decstbm, - decslrm, - }; -}; - -const State = union(enum) { - /// We're not in a DCS state at the moment. - inactive: void, - - /// We're hooked, but its an unknown DCS command or one that went - /// invalid due to some bad input, so we're ignoring the rest. - ignore: void, - - /// XTGETTCAP - xtgettcap: std.ArrayList(u8), - - /// DECRQSS - decrqss: struct { - data: [2]u8 = undefined, - len: u2 = 0, - }, -}; - -test "unknown DCS command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .final = 'A' }); - try testing.expect(h.state == .ignore); - try testing.expect(h.unhook() == null); - try testing.expect(h.state == .inactive); -} - -test "XTGETTCAP command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78") |byte| h.put(byte); - var cmd = h.unhook().?; - defer cmd.deinit(); - try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); - try testing.expect(cmd.xtgettcap.next() == null); -} - -test "XTGETTCAP command multiple keys" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78;536D756C78") |byte| h.put(byte); - var cmd = h.unhook().?; - defer cmd.deinit(); - try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); - try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); - try testing.expect(cmd.xtgettcap.next() == null); -} - -test "XTGETTCAP command invalid data" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("who;536D756C78") |byte| h.put(byte); - var cmd = h.unhook().?; - defer cmd.deinit(); - try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("who", cmd.xtgettcap.next().?); - try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); - try testing.expect(cmd.xtgettcap.next() == null); -} - -test "DECRQSS command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('m'); - var cmd = h.unhook().?; - defer cmd.deinit(); - try testing.expect(cmd == .decrqss); - try testing.expect(cmd.decrqss == .sgr); -} - -test "DECRQSS invalid command" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('z'); - var cmd = h.unhook().?; - defer cmd.deinit(); - try testing.expect(cmd == .decrqss); - try testing.expect(cmd.decrqss == .none); - - h.discard(); - - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('"'); - h.put(' '); - h.put('q'); - try testing.expect(h.unhook() == null); -} diff --git a/src/terminal-old/device_status.zig b/src/terminal-old/device_status.zig deleted file mode 100644 index 78147ddd40..0000000000 --- a/src/terminal-old/device_status.zig +++ /dev/null @@ -1,67 +0,0 @@ -const std = @import("std"); - -/// An enum(u16) of the available device status requests. -pub const Request = dsr_enum: { - const EnumField = std.builtin.Type.EnumField; - var fields: [entries.len]EnumField = undefined; - for (entries, 0..) |entry, i| { - fields[i] = .{ - .name = entry.name, - .value = @as(Tag.Backing, @bitCast(Tag{ - .value = entry.value, - .question = entry.question, - })), - }; - } - - break :dsr_enum @Type(.{ .Enum = .{ - .tag_type = Tag.Backing, - .fields = &fields, - .decls = &.{}, - .is_exhaustive = true, - } }); -}; - -/// The tag type for our enum is a u16 but we use a packed struct -/// in order to pack the question bit into the tag. The "u16" size is -/// chosen somewhat arbitrarily to match the largest expected size -/// we see as a multiple of 8 bits. -pub const Tag = packed struct(u16) { - pub const Backing = @typeInfo(@This()).Struct.backing_integer.?; - value: u15, - question: bool = false, - - test "order" { - const t: Tag = .{ .value = 1 }; - const int: Backing = @bitCast(t); - try std.testing.expectEqual(@as(Backing, 1), int); - } -}; - -pub fn reqFromInt(v: u16, question: bool) ?Request { - inline for (entries) |entry| { - if (entry.value == v and entry.question == question) { - const tag: Tag = .{ .question = question, .value = entry.value }; - const int: Tag.Backing = @bitCast(tag); - return @enumFromInt(int); - } - } - - return null; -} - -/// A single entry of a possible device status request we support. The -/// "question" field determines if it is valid with or without the "?" -/// prefix. -const Entry = struct { - name: [:0]const u8, - value: comptime_int, - question: bool = false, // "?" request -}; - -/// The full list of device status request entries. -const entries: []const Entry = &.{ - .{ .name = "operating_status", .value = 5 }, - .{ .name = "cursor_position", .value = 6 }, - .{ .name = "color_scheme", .value = 996, .question = true }, -}; diff --git a/src/terminal-old/kitty.zig b/src/terminal-old/kitty.zig deleted file mode 100644 index 497dd4aba1..0000000000 --- a/src/terminal-old/kitty.zig +++ /dev/null @@ -1,8 +0,0 @@ -//! Types and functions related to Kitty protocols. - -pub const graphics = @import("kitty/graphics.zig"); -pub usingnamespace @import("kitty/key.zig"); - -test { - @import("std").testing.refAllDecls(@This()); -} diff --git a/src/terminal-old/kitty/graphics.zig b/src/terminal-old/kitty/graphics.zig deleted file mode 100644 index cfc45adbc4..0000000000 --- a/src/terminal-old/kitty/graphics.zig +++ /dev/null @@ -1,22 +0,0 @@ -//! Kitty graphics protocol support. -//! -//! Documentation: -//! https://sw.kovidgoyal.net/kitty/graphics-protocol -//! -//! Unimplemented features that are still todo: -//! - shared memory transmit -//! - virtual placement w/ unicode -//! - animation -//! -//! Performance: -//! The performance of this particular subsystem of Ghostty is not great. -//! We can avoid a lot more allocations, we can replace some C code (which -//! implicitly allocates) with native Zig, we can improve the data structures -//! to avoid repeated lookups, etc. I tried to avoid pessimization but my -//! aim to ship a v1 of this implementation came at some cost. I learned a lot -//! though and I think we can go back through and fix this up. - -pub usingnamespace @import("graphics_command.zig"); -pub usingnamespace @import("graphics_exec.zig"); -pub usingnamespace @import("graphics_image.zig"); -pub usingnamespace @import("graphics_storage.zig"); diff --git a/src/terminal-old/kitty/graphics_command.zig b/src/terminal-old/kitty/graphics_command.zig deleted file mode 100644 index ca7a4d674b..0000000000 --- a/src/terminal-old/kitty/graphics_command.zig +++ /dev/null @@ -1,984 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -/// The key-value pairs for the control information for a command. The -/// keys are always single characters and the values are either single -/// characters or 32-bit unsigned integers. -/// -/// For the value of this: if the value is a single printable ASCII character -/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer. -const KV = std.AutoHashMapUnmanaged(u8, u32); - -/// Command parser parses the Kitty graphics protocol escape sequence. -pub const CommandParser = struct { - /// The memory used by the parser is stored in an arena because it is - /// all freed at the end of the command. - arena: ArenaAllocator, - - /// This is the list of KV pairs that we're building up. - kv: KV = .{}, - - /// This is used as a buffer to store the key/value of a KV pair. - /// The value of a KV pair is at most a 32-bit integer which at most - /// is 10 characters (4294967295). - kv_temp: [10]u8 = undefined, - kv_temp_len: u4 = 0, - kv_current: u8 = 0, // Current kv key - - /// This is the list of bytes that contains both KV data and final - /// data. You shouldn't access this directly. - data: std.ArrayList(u8), - - /// Internal state for parsing. - state: State = .control_key, - - const State = enum { - /// Parsing k/v pairs. The "ignore" variants are in that state - /// but ignore any data because we know they're invalid. - control_key, - control_key_ignore, - control_value, - control_value_ignore, - - /// We're parsing the data blob. - data, - }; - - /// Initialize the parser. The allocator given will be used for both - /// temporary data and long-lived values such as the final image blob. - pub fn init(alloc: Allocator) CommandParser { - var arena = ArenaAllocator.init(alloc); - errdefer arena.deinit(); - return .{ - .arena = arena, - .data = std.ArrayList(u8).init(alloc), - }; - } - - pub fn deinit(self: *CommandParser) void { - // We don't free the hash map because its in the arena - self.arena.deinit(); - self.data.deinit(); - } - - /// Feed a single byte to the parser. - /// - /// The first byte to start parsing should be the byte immediately following - /// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should - /// be "1". - pub fn feed(self: *CommandParser, c: u8) !void { - switch (self.state) { - .control_key => switch (c) { - // '=' means the key is complete and we're moving to the value. - '=' => if (self.kv_temp_len != 1) { - // All control keys are a single character right now so - // if we're not a single character just ignore follow-up - // data. - self.state = .control_value_ignore; - self.kv_temp_len = 0; - } else { - self.kv_current = self.kv_temp[0]; - self.kv_temp_len = 0; - self.state = .control_value; - }, - - else => try self.accumulateValue(c, .control_key_ignore), - }, - - .control_key_ignore => switch (c) { - '=' => self.state = .control_value_ignore, - else => {}, - }, - - .control_value => switch (c) { - ',' => try self.finishValue(.control_key), // move to next key - ';' => try self.finishValue(.data), // move to data - else => try self.accumulateValue(c, .control_value_ignore), - }, - - .control_value_ignore => switch (c) { - ',' => self.state = .control_key_ignore, - ';' => self.state = .data, - else => {}, - }, - - .data => try self.data.append(c), - } - - // We always add to our data list because this is our stable - // array of bytes that we'll reference everywhere else. - } - - /// Complete the parsing. This must be called after all the - /// bytes have been fed to the parser. - /// - /// The allocator given will be used for the long-lived data - /// of the final command. - pub fn complete(self: *CommandParser) !Command { - switch (self.state) { - // We can't ever end in the control key state and be valid. - // This means the command looked something like "a=1,b" - .control_key, .control_key_ignore => return error.InvalidFormat, - - // Some commands (i.e. placements) end without extra data so - // we end in the value state. i.e. "a=1,b=2" - .control_value => try self.finishValue(.data), - .control_value_ignore => {}, - - // Most commands end in data, i.e. "a=1,b=2;1234" - .data => {}, - } - - // Determine our action, which is always a single character. - const action: u8 = action: { - const value = self.kv.get('a') orelse break :action 't'; - const c = std.math.cast(u8, value) orelse return error.InvalidFormat; - break :action c; - }; - const control: Command.Control = switch (action) { - 'q' => .{ .query = try Transmission.parse(self.kv) }, - 't' => .{ .transmit = try Transmission.parse(self.kv) }, - 'T' => .{ .transmit_and_display = .{ - .transmission = try Transmission.parse(self.kv), - .display = try Display.parse(self.kv), - } }, - 'p' => .{ .display = try Display.parse(self.kv) }, - 'd' => .{ .delete = try Delete.parse(self.kv) }, - 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, - 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, - 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, - else => return error.InvalidFormat, - }; - - // Determine our quiet value - const quiet: Command.Quiet = if (self.kv.get('q')) |v| quiet: { - break :quiet switch (v) { - 0 => .no, - 1 => .ok, - 2 => .failures, - else => return error.InvalidFormat, - }; - } else .no; - - return .{ - .control = control, - .quiet = quiet, - .data = if (self.data.items.len == 0) "" else data: { - break :data try self.data.toOwnedSlice(); - }, - }; - } - - fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void { - const idx = self.kv_temp_len; - self.kv_temp_len += 1; - if (self.kv_temp_len > self.kv_temp.len) { - self.state = overflow_state; - self.kv_temp_len = 0; - return; - } - self.kv_temp[idx] = c; - } - - fn finishValue(self: *CommandParser, next_state: State) !void { - const alloc = self.arena.allocator(); - - // We can move states right away, we don't use it. - self.state = next_state; - - // Check for ASCII chars first - if (self.kv_temp_len == 1) { - const c = self.kv_temp[0]; - if (c < '0' or c > '9') { - try self.kv.put(alloc, self.kv_current, @intCast(c)); - self.kv_temp_len = 0; - return; - } - } - - // Only "z" is currently signed. This is a bit of a kloodge; if more - // fields become signed we can rethink this but for now we parse - // "z" as i32 then bitcast it to u32 then bitcast it back later. - if (self.kv_current == 'z') { - const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10); - try self.kv.put(alloc, self.kv_current, @bitCast(v)); - } else { - const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10); - try self.kv.put(alloc, self.kv_current, v); - } - - // Clear our temp buffer - self.kv_temp_len = 0; - } -}; - -/// Represents a possible response to a command. -pub const Response = struct { - id: u32 = 0, - image_number: u32 = 0, - placement_id: u32 = 0, - message: []const u8 = "OK", - - pub fn encode(self: Response, writer: anytype) !void { - // We only encode a result if we have either an id or an image number. - if (self.id == 0 and self.image_number == 0) return; - - try writer.writeAll("\x1b_G"); - if (self.id > 0) { - try writer.print("i={}", .{self.id}); - } - if (self.image_number > 0) { - if (self.id > 0) try writer.writeByte(','); - try writer.print("I={}", .{self.image_number}); - } - if (self.placement_id > 0) { - try writer.print(",p={}", .{self.placement_id}); - } - try writer.writeByte(';'); - try writer.writeAll(self.message); - try writer.writeAll("\x1b\\"); - } - - /// Returns true if this response is not an error. - pub fn ok(self: Response) bool { - return std.mem.eql(u8, self.message, "OK"); - } -}; - -pub const Command = struct { - control: Control, - quiet: Quiet = .no, - data: []const u8 = "", - - pub const Action = enum { - query, // q - transmit, // t - transmit_and_display, // T - display, // p - delete, // d - transmit_animation_frame, // f - control_animation, // a - compose_animation, // c - }; - - pub const Quiet = enum { - no, // 0 - ok, // 1 - failures, // 2 - }; - - pub const Control = union(Action) { - query: Transmission, - transmit: Transmission, - transmit_and_display: struct { - transmission: Transmission, - display: Display, - }, - display: Display, - delete: Delete, - transmit_animation_frame: AnimationFrameLoading, - control_animation: AnimationControl, - compose_animation: AnimationFrameComposition, - }; - - /// Take ownership over the data in this command. If the returned value - /// has a length of zero, then the data was empty and need not be freed. - pub fn toOwnedData(self: *Command) []const u8 { - const result = self.data; - self.data = ""; - return result; - } - - /// Returns the transmission data if it has any. - pub fn transmission(self: Command) ?Transmission { - return switch (self.control) { - .query => |t| t, - .transmit => |t| t, - .transmit_and_display => |t| t.transmission, - else => null, - }; - } - - /// Returns the display data if it has any. - pub fn display(self: Command) ?Display { - return switch (self.control) { - .display => |d| d, - .transmit_and_display => |t| t.display, - else => null, - }; - } - - pub fn deinit(self: Command, alloc: Allocator) void { - if (self.data.len > 0) alloc.free(self.data); - } -}; - -pub const Transmission = struct { - format: Format = .rgb, // f - medium: Medium = .direct, // t - width: u32 = 0, // s - height: u32 = 0, // v - size: u32 = 0, // S - offset: u32 = 0, // O - image_id: u32 = 0, // i - image_number: u32 = 0, // I - placement_id: u32 = 0, // p - compression: Compression = .none, // o - more_chunks: bool = false, // m - - pub const Format = enum { - rgb, // 24 - rgba, // 32 - png, // 100 - - // The following are not supported directly via the protocol - // but they are formats that a png may decode to that we - // support. - grey_alpha, - }; - - pub const Medium = enum { - direct, // d - file, // f - temporary_file, // t - shared_memory, // s - }; - - pub const Compression = enum { - none, - zlib_deflate, // z - }; - - fn parse(kv: KV) !Transmission { - var result: Transmission = .{}; - if (kv.get('f')) |v| { - result.format = switch (v) { - 24 => .rgb, - 32 => .rgba, - 100 => .png, - else => return error.InvalidFormat, - }; - } - - if (kv.get('t')) |v| { - const c = std.math.cast(u8, v) orelse return error.InvalidFormat; - result.medium = switch (c) { - 'd' => .direct, - 'f' => .file, - 't' => .temporary_file, - 's' => .shared_memory, - else => return error.InvalidFormat, - }; - } - - if (kv.get('s')) |v| { - result.width = v; - } - - if (kv.get('v')) |v| { - result.height = v; - } - - if (kv.get('S')) |v| { - result.size = v; - } - - if (kv.get('O')) |v| { - result.offset = v; - } - - if (kv.get('i')) |v| { - result.image_id = v; - } - - if (kv.get('I')) |v| { - result.image_number = v; - } - - if (kv.get('p')) |v| { - result.placement_id = v; - } - - if (kv.get('o')) |v| { - const c = std.math.cast(u8, v) orelse return error.InvalidFormat; - result.compression = switch (c) { - 'z' => .zlib_deflate, - else => return error.InvalidFormat, - }; - } - - if (kv.get('m')) |v| { - result.more_chunks = v > 0; - } - - return result; - } -}; - -pub const Display = struct { - image_id: u32 = 0, // i - image_number: u32 = 0, // I - placement_id: u32 = 0, // p - x: u32 = 0, // x - y: u32 = 0, // y - width: u32 = 0, // w - height: u32 = 0, // h - x_offset: u32 = 0, // X - y_offset: u32 = 0, // Y - columns: u32 = 0, // c - rows: u32 = 0, // r - cursor_movement: CursorMovement = .after, // C - virtual_placement: bool = false, // U - z: i32 = 0, // z - - pub const CursorMovement = enum { - after, // 0 - none, // 1 - }; - - fn parse(kv: KV) !Display { - var result: Display = .{}; - - if (kv.get('i')) |v| { - result.image_id = v; - } - - if (kv.get('I')) |v| { - result.image_number = v; - } - - if (kv.get('p')) |v| { - result.placement_id = v; - } - - if (kv.get('x')) |v| { - result.x = v; - } - - if (kv.get('y')) |v| { - result.y = v; - } - - if (kv.get('w')) |v| { - result.width = v; - } - - if (kv.get('h')) |v| { - result.height = v; - } - - if (kv.get('X')) |v| { - result.x_offset = v; - } - - if (kv.get('Y')) |v| { - result.y_offset = v; - } - - if (kv.get('c')) |v| { - result.columns = v; - } - - if (kv.get('r')) |v| { - result.rows = v; - } - - if (kv.get('C')) |v| { - result.cursor_movement = switch (v) { - 0 => .after, - 1 => .none, - else => return error.InvalidFormat, - }; - } - - if (kv.get('U')) |v| { - result.virtual_placement = switch (v) { - 0 => false, - 1 => true, - else => return error.InvalidFormat, - }; - } - - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.z = @bitCast(v); - } - - return result; - } -}; - -pub const AnimationFrameLoading = struct { - x: u32 = 0, // x - y: u32 = 0, // y - create_frame: u32 = 0, // c - edit_frame: u32 = 0, // r - gap_ms: u32 = 0, // z - composition_mode: CompositionMode = .alpha_blend, // X - background: Background = .{}, // Y - - pub const Background = packed struct(u32) { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, - a: u8 = 0, - }; - - fn parse(kv: KV) !AnimationFrameLoading { - var result: AnimationFrameLoading = .{}; - - if (kv.get('x')) |v| { - result.x = v; - } - - if (kv.get('y')) |v| { - result.y = v; - } - - if (kv.get('c')) |v| { - result.create_frame = v; - } - - if (kv.get('r')) |v| { - result.edit_frame = v; - } - - if (kv.get('z')) |v| { - result.gap_ms = v; - } - - if (kv.get('X')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; - } - - if (kv.get('Y')) |v| { - result.background = @bitCast(v); - } - - return result; - } -}; - -pub const AnimationFrameComposition = struct { - frame: u32 = 0, // c - edit_frame: u32 = 0, // r - x: u32 = 0, // x - y: u32 = 0, // y - width: u32 = 0, // w - height: u32 = 0, // h - left_edge: u32 = 0, // X - top_edge: u32 = 0, // Y - composition_mode: CompositionMode = .alpha_blend, // C - - fn parse(kv: KV) !AnimationFrameComposition { - var result: AnimationFrameComposition = .{}; - - if (kv.get('c')) |v| { - result.frame = v; - } - - if (kv.get('r')) |v| { - result.edit_frame = v; - } - - if (kv.get('x')) |v| { - result.x = v; - } - - if (kv.get('y')) |v| { - result.y = v; - } - - if (kv.get('w')) |v| { - result.width = v; - } - - if (kv.get('h')) |v| { - result.height = v; - } - - if (kv.get('X')) |v| { - result.left_edge = v; - } - - if (kv.get('Y')) |v| { - result.top_edge = v; - } - - if (kv.get('C')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; - } - - return result; - } -}; - -pub const AnimationControl = struct { - action: AnimationAction = .invalid, // s - frame: u32 = 0, // r - gap_ms: u32 = 0, // z - current_frame: u32 = 0, // c - loops: u32 = 0, // v - - pub const AnimationAction = enum { - invalid, // 0 - stop, // 1 - run_wait, // 2 - run, // 3 - }; - - fn parse(kv: KV) !AnimationControl { - var result: AnimationControl = .{}; - - if (kv.get('s')) |v| { - result.action = switch (v) { - 0 => .invalid, - 1 => .stop, - 2 => .run_wait, - 3 => .run, - else => return error.InvalidFormat, - }; - } - - if (kv.get('r')) |v| { - result.frame = v; - } - - if (kv.get('z')) |v| { - result.gap_ms = v; - } - - if (kv.get('c')) |v| { - result.current_frame = v; - } - - if (kv.get('v')) |v| { - result.loops = v; - } - - return result; - } -}; - -pub const Delete = union(enum) { - // a/A - all: bool, - - // i/I - id: struct { - delete: bool = false, // uppercase - image_id: u32 = 0, // i - placement_id: u32 = 0, // p - }, - - // n/N - newest: struct { - delete: bool = false, // uppercase - image_number: u32 = 0, // I - placement_id: u32 = 0, // p - }, - - // c/C, - intersect_cursor: bool, - - // f/F - animation_frames: bool, - - // p/P - intersect_cell: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - }, - - // q/Q - intersect_cell_z: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - z: i32 = 0, // z - }, - - // x/X - column: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - }, - - // y/Y - row: struct { - delete: bool = false, // uppercase - y: u32 = 0, // y - }, - - // z/Z - z: struct { - delete: bool = false, // uppercase - z: i32 = 0, // z - }, - - fn parse(kv: KV) !Delete { - const what: u8 = what: { - const value = kv.get('d') orelse break :what 'a'; - const c = std.math.cast(u8, value) orelse return error.InvalidFormat; - break :what c; - }; - - return switch (what) { - 'a', 'A' => .{ .all = what == 'A' }, - - 'i', 'I' => blk: { - var result: Delete = .{ .id = .{ .delete = what == 'I' } }; - if (kv.get('i')) |v| { - result.id.image_id = v; - } - if (kv.get('p')) |v| { - result.id.placement_id = v; - } - - break :blk result; - }, - - 'n', 'N' => blk: { - var result: Delete = .{ .newest = .{ .delete = what == 'N' } }; - if (kv.get('I')) |v| { - result.newest.image_number = v; - } - if (kv.get('p')) |v| { - result.newest.placement_id = v; - } - - break :blk result; - }, - - 'c', 'C' => .{ .intersect_cursor = what == 'C' }, - - 'f', 'F' => .{ .animation_frames = what == 'F' }, - - 'p', 'P' => blk: { - var result: Delete = .{ .intersect_cell = .{ .delete = what == 'P' } }; - if (kv.get('x')) |v| { - result.intersect_cell.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell.y = v; - } - - break :blk result; - }, - - 'q', 'Q' => blk: { - var result: Delete = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; - if (kv.get('x')) |v| { - result.intersect_cell_z.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell_z.y = v; - } - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.intersect_cell_z.z = @bitCast(v); - } - - break :blk result; - }, - - 'x', 'X' => blk: { - var result: Delete = .{ .column = .{ .delete = what == 'X' } }; - if (kv.get('x')) |v| { - result.column.x = v; - } - - break :blk result; - }, - - 'y', 'Y' => blk: { - var result: Delete = .{ .row = .{ .delete = what == 'Y' } }; - if (kv.get('y')) |v| { - result.row.y = v; - } - - break :blk result; - }, - - 'z', 'Z' => blk: { - var result: Delete = .{ .z = .{ .delete = what == 'Z' } }; - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.z.z = @bitCast(v); - } - - break :blk result; - }, - - else => return error.InvalidFormat, - }; - } -}; - -pub const CompositionMode = enum { - alpha_blend, // 0 - overwrite, // 1 -}; - -test "transmission command" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "f=24,s=10,v=20"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .transmit); - const v = command.control.transmit; - try testing.expectEqual(Transmission.Format.rgb, v.format); - try testing.expectEqual(@as(u32, 10), v.width); - try testing.expectEqual(@as(u32, 20), v.height); -} - -test "query command" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .query); - const v = command.control.query; - try testing.expectEqual(Transmission.Medium.direct, v.medium); - try testing.expectEqual(@as(u32, 1), v.width); - try testing.expectEqual(@as(u32, 1), v.height); - try testing.expectEqual(@as(u32, 31), v.image_id); - try testing.expectEqualStrings("AAAA", command.data); -} - -test "display command" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "a=p,U=1,i=31,c=80,r=120"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .display); - const v = command.control.display; - try testing.expectEqual(@as(u32, 80), v.columns); - try testing.expectEqual(@as(u32, 120), v.rows); - try testing.expectEqual(@as(u32, 31), v.image_id); -} - -test "delete command" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "a=d,d=p,x=3,y=4"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .delete); - const v = command.control.delete; - try testing.expect(v == .intersect_cell); - const dv = v.intersect_cell; - try testing.expect(!dv.delete); - try testing.expectEqual(@as(u32, 3), dv.x); - try testing.expectEqual(@as(u32, 4), dv.y); -} - -test "ignore unknown keys (long)" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "f=24,s=10,v=20,hello=world"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .transmit); - const v = command.control.transmit; - try testing.expectEqual(Transmission.Format.rgb, v.format); - try testing.expectEqual(@as(u32, 10), v.width); - try testing.expectEqual(@as(u32, 20), v.height); -} - -test "ignore very long values" { - const testing = std.testing; - const alloc = testing.allocator; - var p = CommandParser.init(alloc); - defer p.deinit(); - - const input = "f=24,s=10,v=2000000000000000000000000000000000000000"; - for (input) |c| try p.feed(c); - const command = try p.complete(); - defer command.deinit(alloc); - - try testing.expect(command.control == .transmit); - const v = command.control.transmit; - try testing.expectEqual(Transmission.Format.rgb, v.format); - try testing.expectEqual(@as(u32, 10), v.width); - try testing.expectEqual(@as(u32, 0), v.height); -} - -test "response: encode nothing without ID or image number" { - const testing = std.testing; - var buf: [1024]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - - var r: Response = .{}; - try r.encode(fbs.writer()); - try testing.expectEqualStrings("", fbs.getWritten()); -} - -test "response: encode with only image id" { - const testing = std.testing; - var buf: [1024]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - - var r: Response = .{ .id = 4 }; - try r.encode(fbs.writer()); - try testing.expectEqualStrings("\x1b_Gi=4;OK\x1b\\", fbs.getWritten()); -} - -test "response: encode with only image number" { - const testing = std.testing; - var buf: [1024]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - - var r: Response = .{ .image_number = 4 }; - try r.encode(fbs.writer()); - try testing.expectEqualStrings("\x1b_GI=4;OK\x1b\\", fbs.getWritten()); -} - -test "response: encode with image ID and number" { - const testing = std.testing; - var buf: [1024]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - - var r: Response = .{ .id = 12, .image_number = 4 }; - try r.encode(fbs.writer()); - try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", fbs.getWritten()); -} diff --git a/src/terminal-old/kitty/graphics_exec.zig b/src/terminal-old/kitty/graphics_exec.zig deleted file mode 100644 index b4047c1d5e..0000000000 --- a/src/terminal-old/kitty/graphics_exec.zig +++ /dev/null @@ -1,344 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const point = @import("../point.zig"); -const Terminal = @import("../Terminal.zig"); -const command = @import("graphics_command.zig"); -const image = @import("graphics_image.zig"); -const Command = command.Command; -const Response = command.Response; -const LoadingImage = image.LoadingImage; -const Image = image.Image; -const ImageStorage = @import("graphics_storage.zig").ImageStorage; - -const log = std.log.scoped(.kitty_gfx); - -/// Execute a Kitty graphics command against the given terminal. This -/// will never fail, but the response may indicate an error and the -/// terminal state may not be updated to reflect the command. This will -/// never put the terminal in an unrecoverable state, however. -/// -/// The allocator must be the same allocator that was used to build -/// the command. -pub fn execute( - alloc: Allocator, - terminal: *Terminal, - cmd: *Command, -) ?Response { - // If storage is disabled then we disable the full protocol. This means - // we don't even respond to queries so the terminal completely acts as - // if this feature is not supported. - if (!terminal.screen.kitty_images.enabled()) { - log.debug("kitty graphics requested but disabled", .{}); - return null; - } - - log.debug("executing kitty graphics command: quiet={} control={}", .{ - cmd.quiet, - cmd.control, - }); - - const resp_: ?Response = switch (cmd.control) { - .query => query(alloc, cmd), - .transmit, .transmit_and_display => transmit(alloc, terminal, cmd), - .display => display(alloc, terminal, cmd), - .delete => delete(alloc, terminal, cmd), - - .transmit_animation_frame, - .control_animation, - .compose_animation, - => .{ .message = "ERROR: unimplemented action" }, - }; - - // Handle the quiet settings - if (resp_) |resp| { - if (!resp.ok()) { - log.warn("erroneous kitty graphics response: {s}", .{resp.message}); - } - - return switch (cmd.quiet) { - .no => resp, - .ok => if (resp.ok()) null else resp, - .failures => null, - }; - } - - return null; -} -/// Execute a "query" command. -/// -/// This command is used to attempt to load an image and respond with -/// success/error but does not persist any of the command to the terminal -/// state. -fn query(alloc: Allocator, cmd: *Command) Response { - const t = cmd.control.query; - - // Query requires image ID. We can't actually send a response without - // an image ID either but we return an error and this will be logged - // downstream. - if (t.image_id == 0) { - return .{ .message = "EINVAL: image ID required" }; - } - - // Build a partial response to start - var result: Response = .{ - .id = t.image_id, - .image_number = t.image_number, - .placement_id = t.placement_id, - }; - - // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd) catch |err| { - encodeError(&result, err); - return result; - }; - loading.deinit(alloc); - - return result; -} - -/// Transmit image data. -/// -/// This loads the image, validates it, and puts it into the terminal -/// screen storage. It does not display the image. -fn transmit( - alloc: Allocator, - terminal: *Terminal, - cmd: *Command, -) Response { - const t = cmd.transmission().?; - var result: Response = .{ - .id = t.image_id, - .image_number = t.image_number, - .placement_id = t.placement_id, - }; - if (t.image_id > 0 and t.image_number > 0) { - return .{ .message = "EINVAL: image ID and number are mutually exclusive" }; - } - - const load = loadAndAddImage(alloc, terminal, cmd) catch |err| { - encodeError(&result, err); - return result; - }; - errdefer load.image.deinit(alloc); - - // If we're also displaying, then do that now. This function does - // both transmit and transmit and display. The display might also be - // deferred if it is multi-chunk. - if (load.display) |d| { - assert(!load.more); - var d_copy = d; - d_copy.image_id = load.image.id; - return display(alloc, terminal, &.{ - .control = .{ .display = d_copy }, - .quiet = cmd.quiet, - }); - } - - // If there are more chunks expected we do not respond. - if (load.more) return .{}; - - // After the image is added, set the ID in case it changed - result.id = load.image.id; - - // If the original request had an image number, then we respond. - // Otherwise, we don't respond. - if (load.image.number == 0) return .{}; - - return result; -} - -/// Display a previously transmitted image. -fn display( - alloc: Allocator, - terminal: *Terminal, - cmd: *const Command, -) Response { - const d = cmd.display().?; - - // Display requires image ID or number. - if (d.image_id == 0 and d.image_number == 0) { - return .{ .message = "EINVAL: image ID or number required" }; - } - - // Build up our response - var result: Response = .{ - .id = d.image_id, - .image_number = d.image_number, - .placement_id = d.placement_id, - }; - - // Verify the requested image exists if we have an ID - const storage = &terminal.screen.kitty_images; - const img_: ?Image = if (d.image_id != 0) - storage.imageById(d.image_id) - else - storage.imageByNumber(d.image_number); - const img = img_ orelse { - result.message = "EINVAL: image not found"; - return result; - }; - - // Make sure our response has the image id in case we looked up by number - result.id = img.id; - - // Determine the screen point for the placement. - const placement_point = (point.Viewport{ - .x = terminal.screen.cursor.x, - .y = terminal.screen.cursor.y, - }).toScreen(&terminal.screen); - - // Add the placement - const p: ImageStorage.Placement = .{ - .point = placement_point, - .x_offset = d.x_offset, - .y_offset = d.y_offset, - .source_x = d.x, - .source_y = d.y, - .source_width = d.width, - .source_height = d.height, - .columns = d.columns, - .rows = d.rows, - .z = d.z, - }; - storage.addPlacement( - alloc, - img.id, - result.placement_id, - p, - ) catch |err| { - encodeError(&result, err); - return result; - }; - - // Cursor needs to move after placement - switch (d.cursor_movement) { - .none => {}, - .after => { - const rect = p.rect(img, terminal); - - // We can do better by doing this with pure internal screen state - // but this handles scroll regions. - const height = rect.bottom_right.y - rect.top_left.y; - for (0..height) |_| terminal.index() catch |err| { - log.warn("failed to move cursor: {}", .{err}); - break; - }; - - terminal.setCursorPos( - terminal.screen.cursor.y, - rect.bottom_right.x + 1, - ); - }, - } - - // Display does not result in a response on success - return .{}; -} - -/// Display a previously transmitted image. -fn delete( - alloc: Allocator, - terminal: *Terminal, - cmd: *Command, -) Response { - const storage = &terminal.screen.kitty_images; - storage.delete(alloc, terminal, cmd.control.delete); - - // Delete never responds on success - return .{}; -} - -fn loadAndAddImage( - alloc: Allocator, - terminal: *Terminal, - cmd: *Command, -) !struct { - image: Image, - more: bool = false, - display: ?command.Display = null, -} { - const t = cmd.transmission().?; - const storage = &terminal.screen.kitty_images; - - // Determine our image. This also handles chunking and early exit. - var loading: LoadingImage = if (storage.loading) |loading| loading: { - // Note: we do NOT want to call "cmd.toOwnedData" here because - // we're _copying_ the data. We want the command data to be freed. - try loading.addData(alloc, cmd.data); - - // If we have more then we're done - if (t.more_chunks) return .{ .image = loading.image, .more = true }; - - // We have no more chunks. We're going to be completing the - // image so we want to destroy the pointer to the loading - // image and copy it out. - defer { - alloc.destroy(loading); - storage.loading = null; - } - - break :loading loading.*; - } else try LoadingImage.init(alloc, cmd); - - // We only want to deinit on error. If we're chunking, then we don't - // want to deinit at all. If we're not chunking, then we'll deinit - // after we've copied the image out. - errdefer loading.deinit(alloc); - - // If the image has no ID, we assign one - if (loading.image.id == 0) { - loading.image.id = storage.next_image_id; - storage.next_image_id +%= 1; - } - - // If this is chunked, this is the beginning of a new chunked transmission. - // (We checked for an in-progress chunk above.) - if (t.more_chunks) { - // We allocate the pointer on the heap because its rare and we - // don't want to always pay the memory cost to keep it around. - const loading_ptr = try alloc.create(LoadingImage); - errdefer alloc.destroy(loading_ptr); - loading_ptr.* = loading; - storage.loading = loading_ptr; - return .{ .image = loading.image, .more = true }; - } - - // Dump the image data before it is decompressed - // loading.debugDump() catch unreachable; - - // Validate and store our image - var img = try loading.complete(alloc); - errdefer img.deinit(alloc); - try storage.addImage(alloc, img); - - // Get our display settings - const display_ = loading.display; - - // Ensure we deinit the loading state because we're done. The image - // won't be deinit because of "complete" above. - loading.deinit(alloc); - - return .{ .image = img, .display = display_ }; -} - -const EncodeableError = Image.Error || Allocator.Error; - -/// Encode an error code into a message for a response. -fn encodeError(r: *Response, err: EncodeableError) void { - switch (err) { - error.OutOfMemory => r.message = "ENOMEM: out of memory", - error.InternalError => r.message = "EINVAL: internal error", - error.InvalidData => r.message = "EINVAL: invalid data", - error.DecompressionFailed => r.message = "EINVAL: decompression failed", - error.FilePathTooLong => r.message = "EINVAL: file path too long", - error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", - error.UnsupportedFormat => r.message = "EINVAL: unsupported format", - error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", - error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", - error.DimensionsRequired => r.message = "EINVAL: dimensions required", - error.DimensionsTooLarge => r.message = "EINVAL: dimensions too large", - } -} diff --git a/src/terminal-old/kitty/graphics_image.zig b/src/terminal-old/kitty/graphics_image.zig deleted file mode 100644 index 249d8878f9..0000000000 --- a/src/terminal-old/kitty/graphics_image.zig +++ /dev/null @@ -1,776 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -const command = @import("graphics_command.zig"); -const point = @import("../point.zig"); -const internal_os = @import("../../os/main.zig"); -const stb = @import("../../stb/main.zig"); - -const log = std.log.scoped(.kitty_gfx); - -/// Maximum width or height of an image. Taken directly from Kitty. -const max_dimension = 10000; - -/// Maximum size in bytes, taken from Kitty. -const max_size = 400 * 1024 * 1024; // 400MB - -/// An image that is still being loaded. The image should be initialized -/// using init on the first chunk and then addData for each subsequent -/// chunk. Once all chunks have been added, complete should be called -/// to finalize the image. -pub const LoadingImage = struct { - /// The in-progress image. The first chunk must have all the metadata - /// so this comes from that initially. - image: Image, - - /// The data that is being built up. - data: std.ArrayListUnmanaged(u8) = .{}, - - /// This is non-null when a transmit and display command is given - /// so that we display the image after it is fully loaded. - display: ?command.Display = null, - - /// Initialize a chunked immage from the first image transmission. - /// If this is a multi-chunk image, this should only be the FIRST - /// chunk. - pub fn init(alloc: Allocator, cmd: *command.Command) !LoadingImage { - // Build our initial image from the properties sent via the control. - // These can be overwritten by the data loading process. For example, - // PNG loading sets the width/height from the data. - const t = cmd.transmission().?; - var result: LoadingImage = .{ - .image = .{ - .id = t.image_id, - .number = t.image_number, - .width = t.width, - .height = t.height, - .compression = t.compression, - .format = t.format, - }, - - .display = cmd.display(), - }; - - // Special case for the direct medium, we just add it directly - // which will handle copying the data, base64 decoding, etc. - if (t.medium == .direct) { - try result.addData(alloc, cmd.data); - return result; - } - - // For every other medium, we'll need to at least base64 decode - // the data to make it useful so let's do that. Also, all the data - // has to be path data so we can put it in a stack-allocated buffer. - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const Base64Decoder = std.base64.standard.Decoder; - const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| { - log.warn("failed to calculate base64 size for file path: {}", .{err}); - return error.InvalidData; - }; - if (size > buf.len) return error.FilePathTooLong; - Base64Decoder.decode(&buf, cmd.data) catch |err| { - log.warn("failed to decode base64 data: {}", .{err}); - return error.InvalidData; - }; - - if (comptime builtin.os.tag != .windows) { - if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) { - // std.posix.realpath *asserts* that the path does not have - // internal nulls instead of erroring. - log.warn("failed to get absolute path: BadPathName", .{}); - return error.InvalidData; - } - } - - var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = std.posix.realpath(buf[0..size], &abs_buf) catch |err| { - log.warn("failed to get absolute path: {}", .{err}); - return error.InvalidData; - }; - - // Depending on the medium, load the data from the path. - switch (t.medium) { - .direct => unreachable, // handled above - .file => try result.readFile(.file, alloc, t, path), - .temporary_file => try result.readFile(.temporary_file, alloc, t, path), - .shared_memory => try result.readSharedMemory(alloc, t, path), - } - - return result; - } - - /// Reads the data from a shared memory segment. - fn readSharedMemory( - self: *LoadingImage, - alloc: Allocator, - t: command.Transmission, - path: []const u8, - ) !void { - // We require libc for this for shm_open - if (comptime !builtin.link_libc) return error.UnsupportedMedium; - - // Todo: support shared memory - _ = self; - _ = alloc; - _ = t; - _ = path; - return error.UnsupportedMedium; - } - - /// Reads the data from a temporary file and returns it. This allocates - /// and does not free any of the data, so the caller must free it. - /// - /// This will also delete the temporary file if it is in a safe location. - fn readFile( - self: *LoadingImage, - comptime medium: command.Transmission.Medium, - alloc: Allocator, - t: command.Transmission, - path: []const u8, - ) !void { - switch (medium) { - .file, .temporary_file => {}, - else => @compileError("readFile only supports file and temporary_file"), - } - - // Verify file seems "safe". This is logic copied directly from Kitty, - // mostly. This is really rough but it will catch obvious bad actors. - if (std.mem.startsWith(u8, path, "/proc/") or - std.mem.startsWith(u8, path, "/sys/") or - (std.mem.startsWith(u8, path, "/dev/") and - !std.mem.startsWith(u8, path, "/dev/shm/"))) - { - return error.InvalidData; - } - - // Temporary file logic - if (medium == .temporary_file) { - if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; - } - defer if (medium == .temporary_file) { - std.posix.unlink(path) catch |err| { - log.warn("failed to delete temporary file: {}", .{err}); - }; - }; - - var file = std.fs.cwd().openFile(path, .{}) catch |err| { - log.warn("failed to open temporary file: {}", .{err}); - return error.InvalidData; - }; - defer file.close(); - - // File must be a regular file - if (file.stat()) |stat| { - if (stat.kind != .file) { - log.warn("file is not a regular file kind={}", .{stat.kind}); - return error.InvalidData; - } - } else |err| { - log.warn("failed to stat file: {}", .{err}); - return error.InvalidData; - } - - if (t.offset > 0) { - file.seekTo(@intCast(t.offset)) catch |err| { - log.warn("failed to seek to offset {}: {}", .{ t.offset, err }); - return error.InvalidData; - }; - } - - var buf_reader = std.io.bufferedReader(file.reader()); - const reader = buf_reader.reader(); - - // Read the file - var managed = std.ArrayList(u8).init(alloc); - errdefer managed.deinit(); - const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size; - reader.readAllArrayList(&managed, size) catch |err| { - log.warn("failed to read temporary file: {}", .{err}); - return error.InvalidData; - }; - - // Set our data - assert(self.data.items.len == 0); - self.data = .{ .items = managed.items, .capacity = managed.capacity }; - } - - /// Returns true if path appears to be in a temporary directory. - /// Copies logic from Kitty. - fn isPathInTempDir(path: []const u8) bool { - if (std.mem.startsWith(u8, path, "/tmp")) return true; - if (std.mem.startsWith(u8, path, "/dev/shm")) return true; - if (internal_os.allocTmpDir(std.heap.page_allocator)) |dir| { - defer internal_os.freeTmpDir(std.heap.page_allocator, dir); - if (std.mem.startsWith(u8, path, dir)) return true; - - // The temporary dir is sometimes a symlink. On macOS for - // example /tmp is /private/var/... - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - if (std.posix.realpath(dir, &buf)) |real_dir| { - if (std.mem.startsWith(u8, path, real_dir)) return true; - } else |_| {} - } - - return false; - } - - pub fn deinit(self: *LoadingImage, alloc: Allocator) void { - self.image.deinit(alloc); - self.data.deinit(alloc); - } - - pub fn destroy(self: *LoadingImage, alloc: Allocator) void { - self.deinit(alloc); - alloc.destroy(self); - } - - /// Adds a chunk of base64-encoded data to the image. Use this if the - /// image is coming in chunks (the "m" parameter in the protocol). - pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void { - // If no data, skip - if (data.len == 0) return; - - // Grow our array list by size capacity if it needs it - const Base64Decoder = std.base64.standard.Decoder; - const size = Base64Decoder.calcSizeForSlice(data) catch |err| { - log.warn("failed to calculate size for base64 data: {}", .{err}); - return error.InvalidData; - }; - - // If our data would get too big, return an error - if (self.data.items.len + size > max_size) { - log.warn("image data too large max_size={}", .{max_size}); - return error.InvalidData; - } - - try self.data.ensureUnusedCapacity(alloc, size); - - // We decode directly into the arraylist - const start_i = self.data.items.len; - self.data.items.len = start_i + size; - const buf = self.data.items[start_i..]; - Base64Decoder.decode(buf, data) catch |err| switch (err) { - // We have to ignore invalid padding because lots of encoders - // add the wrong padding. Since we validate image data later - // (PNG decode or simple dimensions check), we can ignore this. - error.InvalidPadding => {}, - - else => { - log.warn("failed to decode base64 data: {}", .{err}); - return error.InvalidData; - }, - }; - } - - /// Complete the chunked image, returning a completed image. - pub fn complete(self: *LoadingImage, alloc: Allocator) !Image { - const img = &self.image; - - // Decompress the data if it is compressed. - try self.decompress(alloc); - - // Decode the png if we have to - if (img.format == .png) try self.decodePng(alloc); - - // Validate our dimensions. - if (img.width == 0 or img.height == 0) return error.DimensionsRequired; - if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; - - // Data length must be what we expect - const bpp: u32 = switch (img.format) { - .grey_alpha => 2, - .rgb => 3, - .rgba => 4, - .png => unreachable, // png should be decoded by here - }; - const expected_len = img.width * img.height * bpp; - const actual_len = self.data.items.len; - if (actual_len != expected_len) { - std.log.warn( - "unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}", - .{ img.id, img.width, img.height, bpp, expected_len, actual_len }, - ); - return error.InvalidData; - } - - // Set our time - self.image.transmit_time = std.time.Instant.now() catch |err| { - log.warn("failed to get time: {}", .{err}); - return error.InternalError; - }; - - // Everything looks good, copy the image data over. - var result = self.image; - result.data = try self.data.toOwnedSlice(alloc); - errdefer result.deinit(alloc); - self.image = .{}; - return result; - } - - /// Debug function to write the data to a file. This is useful for - /// capturing some test data for unit tests. - pub fn debugDump(self: LoadingImage) !void { - if (comptime builtin.mode != .Debug) @compileError("debugDump in non-debug"); - - var buf: [1024]u8 = undefined; - const filename = try std.fmt.bufPrint( - &buf, - "image-{s}-{s}-{d}x{d}-{}.data", - .{ - @tagName(self.image.format), - @tagName(self.image.compression), - self.image.width, - self.image.height, - self.image.id, - }, - ); - const cwd = std.fs.cwd(); - const f = try cwd.createFile(filename, .{}); - defer f.close(); - - const writer = f.writer(); - try writer.writeAll(self.data.items); - } - - /// Decompress the data in-place. - fn decompress(self: *LoadingImage, alloc: Allocator) !void { - return switch (self.image.compression) { - .none => {}, - .zlib_deflate => self.decompressZlib(alloc), - }; - } - - fn decompressZlib(self: *LoadingImage, alloc: Allocator) !void { - // Open our zlib stream - var fbs = std.io.fixedBufferStream(self.data.items); - var stream = std.compress.zlib.decompressor(fbs.reader()); - - // Write it to an array list - var list = std.ArrayList(u8).init(alloc); - errdefer list.deinit(); - stream.reader().readAllArrayList(&list, max_size) catch |err| { - log.warn("failed to read decompressed data: {}", .{err}); - return error.DecompressionFailed; - }; - - // Empty our current data list, take ownership over managed array list - self.data.deinit(alloc); - self.data = .{ .items = list.items, .capacity = list.capacity }; - - // Make sure we note that our image is no longer compressed - self.image.compression = .none; - } - - /// Decode the data as PNG. This will also updated the image dimensions. - fn decodePng(self: *LoadingImage, alloc: Allocator) !void { - assert(self.image.format == .png); - - // Decode PNG - var width: c_int = 0; - var height: c_int = 0; - var bpp: c_int = 0; - const data = stb.stbi_load_from_memory( - self.data.items.ptr, - @intCast(self.data.items.len), - &width, - &height, - &bpp, - 0, - ) orelse return error.InvalidData; - defer stb.stbi_image_free(data); - const len: usize = @intCast(width * height * bpp); - if (len > max_size) { - log.warn("png image too large size={} max_size={}", .{ len, max_size }); - return error.InvalidData; - } - - // Validate our bpp - if (bpp < 2 or bpp > 4) { - log.warn("png with unsupported bpp={}", .{bpp}); - return error.UnsupportedDepth; - } - - // Replace our data - self.data.deinit(alloc); - self.data = .{}; - try self.data.ensureUnusedCapacity(alloc, len); - try self.data.appendSlice(alloc, data[0..len]); - - // Store updated image dimensions - self.image.width = @intCast(width); - self.image.height = @intCast(height); - self.image.format = switch (bpp) { - 2 => .grey_alpha, - 3 => .rgb, - 4 => .rgba, - else => unreachable, // validated above - }; - } -}; - -/// Image represents a single fully loaded image. -pub const Image = struct { - id: u32 = 0, - number: u32 = 0, - width: u32 = 0, - height: u32 = 0, - format: command.Transmission.Format = .rgb, - compression: command.Transmission.Compression = .none, - data: []const u8 = "", - transmit_time: std.time.Instant = undefined, - - pub const Error = error{ - InternalError, - InvalidData, - DecompressionFailed, - DimensionsRequired, - DimensionsTooLarge, - FilePathTooLong, - TemporaryFileNotInTempDir, - UnsupportedFormat, - UnsupportedMedium, - UnsupportedDepth, - }; - - pub fn deinit(self: *Image, alloc: Allocator) void { - if (self.data.len > 0) alloc.free(self.data); - } - - /// Mostly for logging - pub fn withoutData(self: *const Image) Image { - var copy = self.*; - copy.data = ""; - return copy; - } -}; - -/// The rect taken up by some image placement, in grid cells. This will -/// 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; - } -}; - -/// Easy base64 encoding function. -fn testB64(alloc: Allocator, data: []const u8) ![]const u8 { - const B64Encoder = std.base64.standard.Encoder; - const b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len)); - errdefer alloc.free(b64); - return B64Encoder.encode(b64, data); -} - -/// Easy base64 decoding function. -fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 { - const B64Decoder = std.base64.standard.Decoder; - const result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data)); - errdefer alloc.free(result); - try B64Decoder.decode(result, data); - return result; -} - -// This specifically tests we ALLOW invalid RGB data because Kitty -// documents that this should work. -test "image load with invalid RGB data" { - const testing = std.testing; - const alloc = testing.allocator; - - // _Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\ - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .width = 1, - .height = 1, - .image_id = 31, - } }, - .data = try alloc.dupe(u8, "AAAA"), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); -} - -test "image load with image too wide" { - const testing = std.testing; - const alloc = testing.allocator; - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .width = max_dimension + 1, - .height = 1, - .image_id = 31, - } }, - .data = try alloc.dupe(u8, "AAAA"), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); -} - -test "image load with image too tall" { - const testing = std.testing; - const alloc = testing.allocator; - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .height = max_dimension + 1, - .width = 1, - .image_id = 31, - } }, - .data = try alloc.dupe(u8, "AAAA"), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); -} - -test "image load: rgb, zlib compressed, direct" { - const testing = std.testing; - const alloc = testing.allocator; - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .direct, - .compression = .zlib_deflate, - .height = 96, - .width = 128, - .image_id = 31, - } }, - .data = try alloc.dupe( - u8, - @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"), - ), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - var img = try loading.complete(alloc); - defer img.deinit(alloc); - - // should be decompressed - try testing.expect(img.compression == .none); -} - -test "image load: rgb, not compressed, direct" { - const testing = std.testing; - const alloc = testing.allocator; - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .direct, - .compression = .none, - .width = 20, - .height = 15, - .image_id = 31, - } }, - .data = try alloc.dupe( - u8, - @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), - ), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - var img = try loading.complete(alloc); - defer img.deinit(alloc); - - // should be decompressed - try testing.expect(img.compression == .none); -} - -test "image load: rgb, zlib compressed, direct, chunked" { - const testing = std.testing; - const alloc = testing.allocator; - - const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); - - // Setup our initial chunk - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .direct, - .compression = .zlib_deflate, - .height = 96, - .width = 128, - .image_id = 31, - .more_chunks = true, - } }, - .data = try alloc.dupe(u8, data[0..1024]), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - - // Read our remaining chunks - var fbs = std.io.fixedBufferStream(data[1024..]); - var buf: [1024]u8 = undefined; - while (fbs.reader().readAll(&buf)) |size| { - try loading.addData(alloc, buf[0..size]); - if (size < buf.len) break; - } else |err| return err; - - // Complete - var img = try loading.complete(alloc); - defer img.deinit(alloc); - try testing.expect(img.compression == .none); -} - -test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" { - const testing = std.testing; - const alloc = testing.allocator; - - const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"); - - // Setup our initial chunk - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .direct, - .compression = .zlib_deflate, - .height = 96, - .width = 128, - .image_id = 31, - .more_chunks = true, - } }, - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - - // Read our remaining chunks - var fbs = std.io.fixedBufferStream(data); - var buf: [1024]u8 = undefined; - while (fbs.reader().readAll(&buf)) |size| { - try loading.addData(alloc, buf[0..size]); - if (size < buf.len) break; - } else |err| return err; - - // Complete - var img = try loading.complete(alloc); - defer img.deinit(alloc); - try testing.expect(img.compression == .none); -} - -test "image load: rgb, not compressed, temporary file" { - const testing = std.testing; - const alloc = testing.allocator; - - var tmp_dir = try internal_os.TempDir.init(); - defer tmp_dir.deinit(); - const data = try testB64Decode( - alloc, - @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), - ); - defer alloc.free(data); - try tmp_dir.dir.writeFile("image.data", data); - - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .temporary_file, - .compression = .none, - .width = 20, - .height = 15, - .image_id = 31, - } }, - .data = try testB64(alloc, path), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - var img = try loading.complete(alloc); - defer img.deinit(alloc); - try testing.expect(img.compression == .none); - - // Temporary file should be gone - try testing.expectError(error.FileNotFound, tmp_dir.dir.access(path, .{})); -} - -test "image load: rgb, not compressed, regular file" { - const testing = std.testing; - const alloc = testing.allocator; - - var tmp_dir = try internal_os.TempDir.init(); - defer tmp_dir.deinit(); - const data = try testB64Decode( - alloc, - @embedFile("testdata/image-rgb-none-20x15-2147483647.data"), - ); - defer alloc.free(data); - try tmp_dir.dir.writeFile("image.data", data); - - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .rgb, - .medium = .file, - .compression = .none, - .width = 20, - .height = 15, - .image_id = 31, - } }, - .data = try testB64(alloc, path), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - var img = try loading.complete(alloc); - defer img.deinit(alloc); - try testing.expect(img.compression == .none); - try tmp_dir.dir.access(path, .{}); -} - -test "image load: png, not compressed, regular file" { - const testing = std.testing; - const alloc = testing.allocator; - - var tmp_dir = try internal_os.TempDir.init(); - defer tmp_dir.deinit(); - const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); - try tmp_dir.dir.writeFile("image.data", data); - - var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); - - var cmd: command.Command = .{ - .control = .{ .transmit = .{ - .format = .png, - .medium = .file, - .compression = .none, - .width = 0, - .height = 0, - .image_id = 31, - } }, - .data = try testB64(alloc, path), - }; - defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); - defer loading.deinit(alloc); - var img = try loading.complete(alloc); - defer img.deinit(alloc); - try testing.expect(img.compression == .none); - try testing.expect(img.format == .rgb); - try tmp_dir.dir.access(path, .{}); -} diff --git a/src/terminal-old/kitty/graphics_storage.zig b/src/terminal-old/kitty/graphics_storage.zig deleted file mode 100644 index 6e4efc55be..0000000000 --- a/src/terminal-old/kitty/graphics_storage.zig +++ /dev/null @@ -1,865 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; - -const terminal = @import("../main.zig"); -const point = @import("../point.zig"); -const command = @import("graphics_command.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); - -/// An image storage is associated with a terminal screen (i.e. main -/// screen, alt screen) and contains all the transmitted images and -/// placements. -pub const ImageStorage = struct { - const ImageMap = std.AutoHashMapUnmanaged(u32, Image); - const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement); - - /// Dirty is set to true if placements or images change. This is - /// purely informational for the renderer and doesn't affect the - /// correctness of the program. The renderer must set this to false - /// if it cares about this value. - dirty: bool = false, - - /// This is the next automatically assigned image ID. We start mid-way - /// through the u32 range to avoid collisions with buggy programs. - next_image_id: u32 = 2147483647, - - /// This is the next automatically assigned placement ID. This is never - /// user-facing so we can start at 0. This is 32-bits because we use - /// the same space for external placement IDs. We can start at zero - /// because any number is valid. - next_internal_placement_id: u32 = 0, - - /// The set of images that are currently known. - images: ImageMap = .{}, - - /// The set of placements for loaded images. - placements: PlacementMap = .{}, - - /// Non-null if there is an in-progress loading image. - loading: ?*LoadingImage = null, - - /// The total bytes of image data that have been loaded and the limit. - /// If the limit is reached, the oldest images will be evicted to make - /// space. Unused images take priority. - total_bytes: usize = 0, - total_limit: usize = 320 * 1000 * 1000, // 320MB - - 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.placements.deinit(alloc); - } - - /// Kitty image protocol is enabled if we have a non-zero limit. - pub fn enabled(self: *const ImageStorage) bool { - return self.total_limit != 0; - } - - /// Sets the limit in bytes for the total amount of image data that - /// can be loaded. If this limit is lower, this will do an eviction - /// if necessary. If the value is zero, then Kitty image protocol will - /// be disabled. - pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void { - // Special case disabling by quickly deleting all - if (limit == 0) { - self.deinit(alloc); - self.* = .{}; - } - - // If we re lowering our limit, check if we need to evict. - if (limit < self.total_bytes) { - const req_bytes = self.total_bytes - limit; - log.info("evicting images to lower limit, evicting={}", .{req_bytes}); - if (!try self.evictImage(alloc, req_bytes)) { - log.warn("failed to evict enough images for required bytes", .{}); - } - } - - self.total_limit = limit; - } - - /// Add an already-loaded image to the storage. This will automatically - /// free any existing image with the same ID. - pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void { - // If the image itself is over the limit, then error immediately - if (img.data.len > self.total_limit) return error.OutOfMemory; - - // If this would put us over the limit, then evict. - const total_bytes = self.total_bytes + img.data.len; - if (total_bytes > self.total_limit) { - const req_bytes = total_bytes - self.total_limit; - log.info("evicting images to make space for {} bytes", .{req_bytes}); - if (!try self.evictImage(alloc, req_bytes)) { - log.warn("failed to evict enough images for required bytes", .{}); - return error.OutOfMemory; - } - } - - // Do the gop op first so if it fails we don't get a partial state - const gop = try self.images.getOrPut(alloc, img.id); - - log.debug("addImage image={}", .{img: { - var copy = img; - copy.data = ""; - break :img copy; - }}); - - // Write our new image - if (gop.found_existing) { - self.total_bytes -= gop.value_ptr.data.len; - gop.value_ptr.deinit(alloc); - } - - gop.value_ptr.* = img; - self.total_bytes += img.data.len; - - self.dirty = true; - } - - /// Add a placement for a given image. The caller must verify in advance - /// the image exists to prevent memory corruption. - pub fn addPlacement( - self: *ImageStorage, - alloc: Allocator, - image_id: u32, - placement_id: u32, - p: Placement, - ) !void { - assert(self.images.get(image_id) != null); - log.debug("placement image_id={} placement_id={} placement={}\n", .{ - image_id, - placement_id, - p, - }); - - // The important piece here is that the placement ID needs to - // be marked internal if it is zero. This allows multiple placements - // to be added for the same image. If it is non-zero, then it is - // an external placement ID and we can only have one placement - // per (image id, placement id) pair. - const key: PlacementKey = .{ - .image_id = image_id, - .placement_id = if (placement_id == 0) .{ - .tag = .internal, - .id = id: { - defer self.next_internal_placement_id +%= 1; - break :id self.next_internal_placement_id; - }, - } else .{ - .tag = .external, - .id = placement_id, - }, - }; - - const gop = try self.placements.getOrPut(alloc, key); - gop.value_ptr.* = p; - - self.dirty = true; - } - - /// 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); - } - - /// Get an image by its number. If the image doesn't exist, return null. - pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image { - var newest: ?Image = null; - - var it = self.images.iterator(); - while (it.next()) |kv| { - if (kv.value_ptr.number == image_number) { - if (newest == null or - kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) - { - newest = kv.value_ptr.*; - } - } - } - - return newest; - } - - /// Delete placements, images. - pub fn delete( - self: *ImageStorage, - alloc: Allocator, - 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); - self.* = .{ - .dirty = true, - .total_limit = self.total_limit, - }; - } else { - // Delete all our placements - self.placements.deinit(alloc); - self.placements = .{}; - self.dirty = true; - }, - - .id => |v| self.deleteById( - alloc, - v.image_id, - v.placement_id, - v.delete, - ), - - .newest => |v| newest: { - const img = self.imageByNumber(v.image_number) orelse break :newest; - self.deleteById(alloc, 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); - }, - - .intersect_cell => |v| { - 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| { - 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| { - 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.x <= v.x and rect.bottom_right.x >= v.x) { - self.placements.removeByPtr(entry.key_ptr); - if (v.delete) self.deleteIfUnused(alloc, img.id); - } - } - - // Mark dirty to force redraw - self.dirty = true; - }, - - .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); - 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); - } - } - - // Mark dirty to force redraw - self.dirty = true; - }, - - .z => |v| { - var it = self.placements.iterator(); - while (it.next()) |entry| { - if (entry.value_ptr.z == v.z) { - const image_id = entry.key_ptr.image_id; - self.placements.removeByPtr(entry.key_ptr); - if (v.delete) self.deleteIfUnused(alloc, image_id); - } - } - - // Mark dirty to force redraw - self.dirty = true; - }, - - // We don't support animation frames yet so they are successfully - // deleted! - .animation_frames => {}, - } - } - - fn deleteById( - self: *ImageStorage, - alloc: Allocator, - image_id: u32, - placement_id: u32, - delete_unused: bool, - ) void { - // If no placement, we delete all placements with the ID - if (placement_id == 0) { - var it = self.placements.iterator(); - while (it.next()) |entry| { - if (entry.key_ptr.image_id == image_id) { - self.placements.removeByPtr(entry.key_ptr); - } - } - } else { - _ = self.placements.remove(.{ - .image_id = image_id, - .placement_id = .{ .tag = .external, .id = placement_id }, - }); - } - - // If this is specified, then we also delete the image - // if it is no longer in use. - if (delete_unused) self.deleteIfUnused(alloc, image_id); - - // Mark dirty to force redraw - self.dirty = true; - } - - /// Delete an image if it is unused. - fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void { - var it = self.placements.iterator(); - while (it.next()) |kv| { - if (kv.key_ptr.image_id == image_id) { - return; - } - } - - // If we get here, we can delete the image. - if (self.images.getEntry(image_id)) |entry| { - self.total_bytes -= entry.value_ptr.data.len; - entry.value_ptr.deinit(alloc); - self.images.removeByPtr(entry.key_ptr); - } - } - - /// Deletes all placements intersecting a screen point. - fn deleteIntersecting( - self: *ImageStorage, - alloc: Allocator, - t: *const terminal.Terminal, - p: point.ScreenPoint, - delete_unused: bool, - filter_ctx: anytype, - comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, - ) void { - 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 (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - self.placements.removeByPtr(entry.key_ptr); - if (delete_unused) self.deleteIfUnused(alloc, img.id); - } - } - - // Mark dirty to force redraw - self.dirty = true; - } - - /// Evict image to make space. This will evict the oldest image, - /// prioritizing unused images first, as recommended by the published - /// Kitty spec. - /// - /// This will evict as many images as necessary to make space for - /// req bytes. - fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool { - assert(req <= self.total_limit); - - // Ironically we allocate to evict. We should probably redesign the - // data structures to avoid this but for now allocating a little - // bit is fine compared to the megabytes we're looking to save. - const Candidate = struct { - id: u32, - time: std.time.Instant, - used: bool, - }; - - var candidates = std.ArrayList(Candidate).init(alloc); - defer candidates.deinit(); - - var it = self.images.iterator(); - while (it.next()) |kv| { - const img = kv.value_ptr; - - // This is a huge waste. See comment above about redesigning - // our data structures to avoid this. Eviction should be very - // rare though and we never have that many images/placements - // so hopefully this will last a long time. - const used = used: { - var p_it = self.placements.iterator(); - while (p_it.next()) |p_kv| { - if (p_kv.key_ptr.image_id == img.id) { - break :used true; - } - } - - break :used false; - }; - - try candidates.append(.{ - .id = img.id, - .time = img.transmit_time, - .used = used, - }); - } - - // Sort - std.mem.sortUnstable( - Candidate, - candidates.items, - {}, - struct { - fn lessThan( - ctx: void, - lhs: Candidate, - rhs: Candidate, - ) bool { - _ = ctx; - - // If they're usage matches, then its based on time. - if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { - .lt => true, - .gt => false, - .eq => lhs.id < rhs.id, - }; - - // If not used, then its a better candidate - return !lhs.used; - } - }.lessThan, - ); - - // They're in order of best to evict. - var evicted: usize = 0; - for (candidates.items) |c| { - // Delete all the placements for this image and the image. - var p_it = self.placements.iterator(); - while (p_it.next()) |entry| { - if (entry.key_ptr.image_id == c.id) { - self.placements.removeByPtr(entry.key_ptr); - } - } - - if (self.images.getEntry(c.id)) |entry| { - log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len }); - - evicted += entry.value_ptr.data.len; - self.total_bytes -= entry.value_ptr.data.len; - - entry.value_ptr.deinit(alloc); - self.images.removeByPtr(entry.key_ptr); - - if (evicted > req) return true; - } - } - - return false; - } - - /// Every placement is uniquely identified by the image ID and the - /// placement ID. If an image ID isn't specified it is assumed to be 0. - /// Likewise, if a placement ID isn't specified it is assumed to be 0. - pub const PlacementKey = struct { - image_id: u32, - placement_id: packed struct { - tag: enum(u1) { internal, external }, - id: u32, - }, - }; - - pub const Placement = struct { - /// 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, - y_offset: u32 = 0, - - /// Source rectangle for the image to pull from - source_x: u32 = 0, - source_y: u32 = 0, - source_width: u32 = 0, - source_height: u32 = 0, - - /// The columns/rows this image occupies. - columns: u32 = 0, - rows: u32 = 0, - - /// The z-index for this placement. - z: i32 = 0, - - /// Returns a selection of the entire rectangle this placement - /// occupies within the screen. - pub fn rect( - self: Placement, - image: Image, - t: *const terminal.Terminal, - ) 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, - }, - }; - } - - // Calculate our cell size. - const terminal_width_f64: f64 = @floatFromInt(t.width_px); - const terminal_height_f64: f64 = @floatFromInt(t.height_px); - const grid_columns_f64: f64 = @floatFromInt(t.cols); - const grid_rows_f64: f64 = @floatFromInt(t.rows); - const cell_width_f64 = terminal_width_f64 / grid_columns_f64; - const cell_height_f64 = terminal_height_f64 / grid_rows_f64; - - // Our image width - const width_px = if (self.source_width > 0) self.source_width else image.width; - const height_px = if (self.source_height > 0) self.source_height else image.height; - - // Calculate our image size in grid cells - const width_f64: f64 = @floatFromInt(width_px); - const height_f64: f64 = @floatFromInt(height_px); - const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); - const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); - - return .{ - .top_left = self.point, - .bottom_right = .{ - .x = @min(self.point.x + width_cells, t.cols - 1), - .y = self.point.y + height_cells, - }, - }; - } - }; -}; - -test "storage: add placement with zero placement id" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .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()); - - // verify the placement is what we expect - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .internal, .id = 0 }, - }) != null); - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .internal, .id = 1 }, - }) != null); -} - -test "storage: delete all placements and images" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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()); -} - -test "storage: delete all placements and images preserves limit" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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(@as(usize, 5000), s.total_limit); -} - -test "storage: delete all placements" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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()); -} - -test "storage: delete all placements by image id" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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()); -} - -test "storage: delete all placements by image id and unused images" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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()); -} - -test "storage: delete placement by specific id" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 3, 3); - defer t.deinit(alloc); - - var s: ImageStorage = .{}; - 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, .{ .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 = .{ - .delete = true, - .image_id = 1, - .placement_id = 2, - } }); - try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 2), s.placements.count()); - try testing.expectEqual(@as(usize, 3), s.images.count()); -} - -test "storage: delete intersecting cursor" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - - 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()); - - // verify the placement is what we expect - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .external, .id = 2 }, - }) != null); -} - -test "storage: delete intersecting cursor plus unused" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - - 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()); - - // verify the placement is what we expect - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .external, .id = 2 }, - }) != null); -} - -test "storage: delete intersecting cursor hits multiple" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - - 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()); -} - -test "storage: delete by column" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - - s.dirty = false; - s.delete(alloc, &t, .{ .column = .{ - .delete = false, - .x = 60, - } }); - try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 1), s.placements.count()); - try testing.expectEqual(@as(usize, 2), s.images.count()); - - // verify the placement is what we expect - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .external, .id = 1 }, - }) != null); -} - -test "storage: delete by row" { - const testing = std.testing; - const alloc = testing.allocator; - var t = try terminal.Terminal.init(alloc, 100, 100); - defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; - - var s: ImageStorage = .{}; - 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, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - - s.dirty = false; - s.delete(alloc, &t, .{ .row = .{ - .delete = false, - .y = 60, - } }); - try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 1), s.placements.count()); - try testing.expectEqual(@as(usize, 2), s.images.count()); - - // verify the placement is what we expect - try testing.expect(s.placements.get(.{ - .image_id = 1, - .placement_id = .{ .tag = .external, .id = 1 }, - }) != null); -} diff --git a/src/terminal-old/kitty/key.zig b/src/terminal-old/kitty/key.zig deleted file mode 100644 index 938bf65b5a..0000000000 --- a/src/terminal-old/kitty/key.zig +++ /dev/null @@ -1,151 +0,0 @@ -//! Kitty keyboard protocol support. - -const std = @import("std"); - -/// Stack for the key flags. This implements the push/pop behavior -/// of the CSI > u and CSI < u sequences. We implement the stack as -/// fixed size to avoid heap allocation. -pub const KeyFlagStack = struct { - const len = 8; - - flags: [len]KeyFlags = .{.{}} ** len, - idx: u3 = 0, - - /// Return the current stack value - pub fn current(self: KeyFlagStack) KeyFlags { - return self.flags[self.idx]; - } - - /// Perform the "set" operation as described in the spec for - /// the CSI = u sequence. - pub fn set( - self: *KeyFlagStack, - mode: KeySetMode, - v: KeyFlags, - ) void { - switch (mode) { - .set => self.flags[self.idx] = v, - .@"or" => self.flags[self.idx] = @bitCast( - self.flags[self.idx].int() | v.int(), - ), - .not => self.flags[self.idx] = @bitCast( - self.flags[self.idx].int() & ~v.int(), - ), - } - } - - /// Push a new set of flags onto the stack. If the stack is full - /// then the oldest entry is evicted. - pub fn push(self: *KeyFlagStack, flags: KeyFlags) void { - // Overflow and wrap around if we're full, which evicts - // the oldest entry. - self.idx +%= 1; - self.flags[self.idx] = flags; - } - - /// Pop `n` entries from the stack. This will just wrap around - /// if `n` is greater than the amount in the stack. - pub fn pop(self: *KeyFlagStack, n: usize) void { - // If n is more than our length then we just reset the stack. - // This also avoids a DoS vector where a malicious client - // could send a huge number of pop commands to waste cpu. - if (n >= self.flags.len) { - self.idx = 0; - self.flags = .{.{}} ** len; - return; - } - - for (0..n) |_| { - self.flags[self.idx] = .{}; - self.idx -%= 1; - } - } - - // Make sure we the overflow works as expected - test { - const testing = std.testing; - var stack: KeyFlagStack = .{}; - stack.idx = stack.flags.len - 1; - stack.idx +%= 1; - try testing.expect(stack.idx == 0); - - stack.idx = 0; - stack.idx -%= 1; - try testing.expect(stack.idx == stack.flags.len - 1); - } -}; - -/// The possible flags for the Kitty keyboard protocol. -pub const KeyFlags = packed struct(u5) { - disambiguate: bool = false, - report_events: bool = false, - report_alternates: bool = false, - report_all: bool = false, - report_associated: bool = false, - - pub fn int(self: KeyFlags) u5 { - return @bitCast(self); - } - - // Its easy to get packed struct ordering wrong so this test checks. - test { - const testing = std.testing; - - try testing.expectEqual( - @as(u5, 0b1), - (KeyFlags{ .disambiguate = true }).int(), - ); - try testing.expectEqual( - @as(u5, 0b10), - (KeyFlags{ .report_events = true }).int(), - ); - } -}; - -/// The possible modes for setting the key flags. -pub const KeySetMode = enum { set, @"or", not }; - -test "KeyFlagStack: push pop" { - const testing = std.testing; - var stack: KeyFlagStack = .{}; - stack.push(.{ .disambiguate = true }); - try testing.expectEqual( - KeyFlags{ .disambiguate = true }, - stack.current(), - ); - - stack.pop(1); - try testing.expectEqual(KeyFlags{}, stack.current()); -} - -test "KeyFlagStack: pop big number" { - const testing = std.testing; - var stack: KeyFlagStack = .{}; - stack.pop(100); - try testing.expectEqual(KeyFlags{}, stack.current()); -} - -test "KeyFlagStack: set" { - const testing = std.testing; - var stack: KeyFlagStack = .{}; - stack.set(.set, .{ .disambiguate = true }); - try testing.expectEqual( - KeyFlags{ .disambiguate = true }, - stack.current(), - ); - - stack.set(.@"or", .{ .report_events = true }); - try testing.expectEqual( - KeyFlags{ - .disambiguate = true, - .report_events = true, - }, - stack.current(), - ); - - stack.set(.not, .{ .report_events = true }); - try testing.expectEqual( - KeyFlags{ .disambiguate = true }, - stack.current(), - ); -} diff --git a/src/terminal-old/kitty/testdata/image-png-none-50x76-2147483647-raw.data b/src/terminal-old/kitty/testdata/image-png-none-50x76-2147483647-raw.data deleted file mode 100644 index 032cb07c722cfd7ee5dd701e3a7407ddcaafc565..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^MnLSt$P6S!Z |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - @field(self.values, entry.name) = value; - }, - } - } - - /// Get the value of a mode. - pub fn get(self: *ModeState, mode: Mode) bool { - switch (mode) { - inline else => |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - return @field(self.values, entry.name); - }, - } - } - - /// Save the state of the given mode. This can then be restored - /// with restore. This will only be accurate if the previous - /// mode was saved exactly once and not restored. Otherwise this - /// will just keep restoring the last stored value in memory. - pub fn save(self: *ModeState, mode: Mode) void { - switch (mode) { - inline else => |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - @field(self.saved, entry.name) = @field(self.values, entry.name); - }, - } - } - - /// See save. This will return the restored value. - pub fn restore(self: *ModeState, mode: Mode) bool { - switch (mode) { - inline else => |mode_comptime| { - const entry = comptime entryForMode(mode_comptime); - @field(self.values, entry.name) = @field(self.saved, entry.name); - return @field(self.values, entry.name); - }, - } - } - - test { - // We have this here so that we explicitly fail when we change the - // size of modes. The size of modes is NOT particularly important, - // we just want to be mentally aware when it happens. - try std.testing.expectEqual(8, @sizeOf(ModePacked)); - } -}; - -/// A packed struct of all the settable modes. This shouldn't -/// be used directly but rather through the ModeState struct. -pub const ModePacked = packed_struct: { - const StructField = std.builtin.Type.StructField; - var fields: [entries.len]StructField = undefined; - for (entries, 0..) |entry, i| { - fields[i] = .{ - .name = entry.name, - .type = bool, - .default_value = &entry.default, - .is_comptime = false, - .alignment = 0, - }; - } - - break :packed_struct @Type(.{ .Struct = .{ - .layout = .@"packed", - .fields = &fields, - .decls = &.{}, - .is_tuple = false, - } }); -}; - -/// An enum(u16) of the available modes. See entries for available values. -pub const Mode = mode_enum: { - const EnumField = std.builtin.Type.EnumField; - var fields: [entries.len]EnumField = undefined; - for (entries, 0..) |entry, i| { - fields[i] = .{ - .name = entry.name, - .value = @as(ModeTag.Backing, @bitCast(ModeTag{ - .value = entry.value, - .ansi = entry.ansi, - })), - }; - } - - break :mode_enum @Type(.{ .Enum = .{ - .tag_type = ModeTag.Backing, - .fields = &fields, - .decls = &.{}, - .is_exhaustive = true, - } }); -}; - -/// The tag type for our enum is a u16 but we use a packed struct -/// in order to pack the ansi bit into the tag. -pub const ModeTag = packed struct(u16) { - pub const Backing = @typeInfo(@This()).Struct.backing_integer.?; - value: u15, - ansi: bool = false, - - test "order" { - const t: ModeTag = .{ .value = 1 }; - const int: Backing = @bitCast(t); - try std.testing.expectEqual(@as(Backing, 1), int); - } -}; - -pub fn modeFromInt(v: u16, ansi: bool) ?Mode { - inline for (entries) |entry| { - if (comptime !entry.disabled) { - if (entry.value == v and entry.ansi == ansi) { - const tag: ModeTag = .{ .ansi = ansi, .value = entry.value }; - const int: ModeTag.Backing = @bitCast(tag); - return @enumFromInt(int); - } - } - } - - return null; -} - -fn entryForMode(comptime mode: Mode) ModeEntry { - @setEvalBranchQuota(10_000); - const name = @tagName(mode); - for (entries) |entry| { - if (std.mem.eql(u8, entry.name, name)) return entry; - } - - unreachable; -} - -/// A single entry of a possible mode we support. This is used to -/// dynamically define the enum and other tables. -const ModeEntry = struct { - name: [:0]const u8, - value: comptime_int, - default: bool = false, - - /// True if this is an ANSI mode, false if its a DEC mode (?-prefixed). - ansi: bool = false, - - /// If true, this mode is disabled and Ghostty will not allow it to be - /// set or queried. The mode enum still has it, allowing Ghostty developers - /// to develop a mode without exposing it to real users. - disabled: bool = false, -}; - -/// The full list of available entries. For documentation see how -/// they're used within Ghostty or google their values. It is not -/// valuable to redocument them all here. -const entries: []const ModeEntry = &.{ - // ANSI - .{ .name = "disable_keyboard", .value = 2, .ansi = true }, // KAM - .{ .name = "insert", .value = 4, .ansi = true }, - .{ .name = "send_receive_mode", .value = 12, .ansi = true, .default = true }, // SRM - .{ .name = "linefeed", .value = 20, .ansi = true }, - - // DEC - .{ .name = "cursor_keys", .value = 1 }, // DECCKM - .{ .name = "132_column", .value = 3 }, - .{ .name = "slow_scroll", .value = 4 }, - .{ .name = "reverse_colors", .value = 5 }, - .{ .name = "origin", .value = 6 }, - .{ .name = "wraparound", .value = 7, .default = true }, - .{ .name = "autorepeat", .value = 8 }, - .{ .name = "mouse_event_x10", .value = 9 }, - .{ .name = "cursor_blinking", .value = 12 }, - .{ .name = "cursor_visible", .value = 25, .default = true }, - .{ .name = "enable_mode_3", .value = 40 }, - .{ .name = "reverse_wrap", .value = 45 }, - .{ .name = "keypad_keys", .value = 66 }, - .{ .name = "enable_left_and_right_margin", .value = 69 }, - .{ .name = "mouse_event_normal", .value = 1000 }, - .{ .name = "mouse_event_button", .value = 1002 }, - .{ .name = "mouse_event_any", .value = 1003 }, - .{ .name = "focus_event", .value = 1004 }, - .{ .name = "mouse_format_utf8", .value = 1005 }, - .{ .name = "mouse_format_sgr", .value = 1006 }, - .{ .name = "mouse_alternate_scroll", .value = 1007, .default = true }, - .{ .name = "mouse_format_urxvt", .value = 1015 }, - .{ .name = "mouse_format_sgr_pixels", .value = 1016 }, - .{ .name = "ignore_keypad_with_numlock", .value = 1035, .default = true }, - .{ .name = "alt_esc_prefix", .value = 1036, .default = true }, - .{ .name = "alt_sends_escape", .value = 1039 }, - .{ .name = "reverse_wrap_extended", .value = 1045 }, - .{ .name = "alt_screen", .value = 1047 }, - .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, - .{ .name = "bracketed_paste", .value = 2004 }, - .{ .name = "synchronized_output", .value = 2026 }, - .{ .name = "grapheme_cluster", .value = 2027 }, - .{ .name = "report_color_scheme", .value = 2031 }, -}; - -test { - _ = Mode; - _ = ModePacked; -} - -test modeFromInt { - try testing.expect(modeFromInt(4, true).? == .insert); - try testing.expect(modeFromInt(9, true) == null); - try testing.expect(modeFromInt(9, false).? == .mouse_event_x10); - try testing.expect(modeFromInt(14, true) == null); -} - -test ModeState { - var state: ModeState = .{}; - - // Normal set/get - try testing.expect(!state.get(.cursor_keys)); - state.set(.cursor_keys, true); - try testing.expect(state.get(.cursor_keys)); - - // Save/restore - state.save(.cursor_keys); - state.set(.cursor_keys, false); - try testing.expect(!state.get(.cursor_keys)); - try testing.expect(state.restore(.cursor_keys)); - try testing.expect(state.get(.cursor_keys)); -} diff --git a/src/terminal-old/mouse_shape.zig b/src/terminal-old/mouse_shape.zig deleted file mode 100644 index cf8f42c4b6..0000000000 --- a/src/terminal-old/mouse_shape.zig +++ /dev/null @@ -1,115 +0,0 @@ -const std = @import("std"); - -/// The possible cursor shapes. Not all app runtimes support these shapes. -/// The shapes are always based on the W3C supported cursor styles so we -/// can have a cross platform list. -// -// Must be kept in sync with ghostty_cursor_shape_e -pub const MouseShape = enum(c_int) { - default, - context_menu, - help, - pointer, - progress, - wait, - cell, - crosshair, - text, - vertical_text, - alias, - copy, - move, - no_drop, - not_allowed, - grab, - grabbing, - all_scroll, - col_resize, - row_resize, - n_resize, - e_resize, - s_resize, - w_resize, - ne_resize, - nw_resize, - se_resize, - sw_resize, - ew_resize, - ns_resize, - nesw_resize, - nwse_resize, - zoom_in, - zoom_out, - - /// Build cursor shape from string or null if its unknown. - pub fn fromString(v: []const u8) ?MouseShape { - return string_map.get(v); - } -}; - -const string_map = std.ComptimeStringMap(MouseShape, .{ - // W3C - .{ "default", .default }, - .{ "context-menu", .context_menu }, - .{ "help", .help }, - .{ "pointer", .pointer }, - .{ "progress", .progress }, - .{ "wait", .wait }, - .{ "cell", .cell }, - .{ "crosshair", .crosshair }, - .{ "text", .text }, - .{ "vertical-text", .vertical_text }, - .{ "alias", .alias }, - .{ "copy", .copy }, - .{ "move", .move }, - .{ "no-drop", .no_drop }, - .{ "not-allowed", .not_allowed }, - .{ "grab", .grab }, - .{ "grabbing", .grabbing }, - .{ "all-scroll", .all_scroll }, - .{ "col-resize", .col_resize }, - .{ "row-resize", .row_resize }, - .{ "n-resize", .n_resize }, - .{ "e-resize", .e_resize }, - .{ "s-resize", .s_resize }, - .{ "w-resize", .w_resize }, - .{ "ne-resize", .ne_resize }, - .{ "nw-resize", .nw_resize }, - .{ "se-resize", .se_resize }, - .{ "sw-resize", .sw_resize }, - .{ "ew-resize", .ew_resize }, - .{ "ns-resize", .ns_resize }, - .{ "nesw-resize", .nesw_resize }, - .{ "nwse-resize", .nwse_resize }, - .{ "zoom-in", .zoom_in }, - .{ "zoom-out", .zoom_out }, - - // xterm/foot - .{ "left_ptr", .default }, - .{ "question_arrow", .help }, - .{ "hand", .pointer }, - .{ "left_ptr_watch", .progress }, - .{ "watch", .wait }, - .{ "cross", .crosshair }, - .{ "xterm", .text }, - .{ "dnd-link", .alias }, - .{ "dnd-copy", .copy }, - .{ "dnd-move", .move }, - .{ "dnd-no-drop", .no_drop }, - .{ "crossed_circle", .not_allowed }, - .{ "hand1", .grab }, - .{ "right_side", .e_resize }, - .{ "top_side", .n_resize }, - .{ "top_right_corner", .ne_resize }, - .{ "top_left_corner", .nw_resize }, - .{ "bottom_side", .s_resize }, - .{ "bottom_right_corner", .se_resize }, - .{ "bottom_left_corner", .sw_resize }, - .{ "left_side", .w_resize }, - .{ "fleur", .all_scroll }, -}); - -test "cursor shape from string" { - const testing = std.testing; - try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); -} diff --git a/src/terminal-old/osc.zig b/src/terminal-old/osc.zig deleted file mode 100644 index a220ea031a..0000000000 --- a/src/terminal-old/osc.zig +++ /dev/null @@ -1,1274 +0,0 @@ -//! OSC (Operating System Command) related functions and types. OSC is -//! another set of control sequences for terminal programs that start with -//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings -//! and other irregular formatting so a dedicated parser is created to handle it. -const osc = @This(); - -const std = @import("std"); -const mem = std.mem; -const assert = std.debug.assert; -const Allocator = mem.Allocator; - -const log = std.log.scoped(.osc); - -pub const Command = union(enum) { - /// Set the window title of the terminal - /// - /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 - /// with each code unit further encoded with two hex digets). - /// - /// If title mode 2 is set or the terminal is setup for unconditional - /// utf-8 titles text is interpreted as utf-8. Else text is interpreted - /// as latin1. - change_window_title: []const u8, - - /// Set the icon of the terminal window. The name of the icon is not - /// well defined, so this is currently ignored by Ghostty at the time - /// of writing this. We just parse it so that we don't get parse errors - /// in the log. - change_window_icon: []const u8, - - /// First do a fresh-line. Then start a new command, and enter prompt mode: - /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a - /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed - /// not all shells will send the prompt end code. - prompt_start: struct { - aid: ?[]const u8 = null, - kind: enum { primary, right, continuation } = .primary, - redraw: bool = true, - }, - - /// End of prompt and start of user input, terminated by a OSC "133;C" - /// or another prompt (OSC "133;P"). - prompt_end: void, - - /// The OSC "133;C" command can be used to explicitly end - /// the input area and begin the output area. However, some applications - /// don't provide a convenient way to emit that command. - /// That is why we also specify an implicit way to end the input area - /// at the end of the line. In the case of multiple input lines: If the - /// cursor is on a fresh (empty) line and we see either OSC "133;P" or - /// OSC "133;I" then this is the start of a continuation input line. - /// If we see anything else, it is the start of the output area (or end - /// of command). - end_of_input: void, - - /// End of current command. - /// - /// The exit-code need not be specified if if there are no options, - /// or if the command was cancelled (no OSC "133;C"), such as by typing - /// an interrupt/cancel character (typically ctrl-C) during line-editing. - /// Otherwise, it must be an integer code, where 0 means the command - /// succeeded, and other values indicate failure. In additing to the - /// exit-code there may be an err= option, which non-legacy terminals - /// should give precedence to. The err=_value_ option is more general: - /// an empty string is success, and any non-empty value (which need not - /// be an integer) is an error code. So to indicate success both ways you - /// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter. - end_of_command: struct { - exit_code: ?u8 = null, - // TODO: err option - }, - - /// Set or get clipboard contents. If data is null, then the current - /// clipboard contents are sent to the pty. If data is set, this - /// contents is set on the clipboard. - clipboard_contents: struct { - kind: u8, - data: []const u8, - }, - - /// OSC 7. Reports the current working directory of the shell. This is - /// a moderately flawed escape sequence but one that many major terminals - /// support so we also support it. To understand the flaws, read through - /// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20 - report_pwd: struct { - /// The reported pwd value. This is not checked for validity. It should - /// be a file URL but it is up to the caller to utilize this value. - value: []const u8, - }, - - /// OSC 22. Set the mouse shape. There doesn't seem to be a standard - /// naming scheme for cursors but it looks like terminals such as Foot - /// are moving towards using the W3C CSS cursor names. For OSC parsing, - /// we just parse whatever string is given. - mouse_shape: struct { - value: []const u8, - }, - - /// OSC 4, OSC 10, and OSC 11 color report. - report_color: struct { - /// OSC 4 requests a palette color, OSC 10 requests the foreground - /// color, OSC 11 the background color. - kind: ColorKind, - - /// We must reply with the same string terminator (ST) as used in the - /// request. - terminator: Terminator = .st, - }, - - /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) - set_color: struct { - /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 - /// the background color. - kind: ColorKind, - - /// The color spec as a string - value: []const u8, - }, - - /// Reset a palette color (OSC 104) or the foreground (OSC 110), background - /// (OSC 111), or cursor (OSC 112) color. - reset_color: struct { - kind: ColorKind, - - /// OSC 104 can have parameters indicating which palette colors to - /// reset. - value: []const u8, - }, - - /// Show a desktop notification (OSC 9 or OSC 777) - show_desktop_notification: struct { - title: []const u8, - body: []const u8, - }, - - pub const ColorKind = union(enum) { - palette: u8, - foreground, - background, - cursor, - - pub fn code(self: ColorKind) []const u8 { - return switch (self) { - .palette => "4", - .foreground => "10", - .background => "11", - .cursor => "12", - }; - } - }; -}; - -/// The terminator used to end an OSC command. For OSC commands that demand -/// a response, we try to match the terminator used in the request since that -/// is most likely to be accepted by the calling program. -pub const Terminator = enum { - /// The preferred string terminator is ESC followed by \ - st, - - /// Some applications and terminals use BELL (0x07) as the string terminator. - bel, - - /// Initialize the terminator based on the last byte seen. If the - /// last byte is a BEL then we use BEL, otherwise we just assume ST. - pub fn init(ch: ?u8) Terminator { - return switch (ch orelse return .st) { - 0x07 => .bel, - else => .st, - }; - } - - /// The terminator as a string. This is static memory so it doesn't - /// need to be freed. - pub fn string(self: Terminator) []const u8 { - return switch (self) { - .st => "\x1b\\", - .bel => "\x07", - }; - } -}; - -pub const Parser = struct { - /// Optional allocator used to accept data longer than MAX_BUF. - /// This only applies to some commands (e.g. OSC 52) that can - /// reasonably exceed MAX_BUF. - alloc: ?Allocator = null, - - /// Current state of the parser. - state: State = .empty, - - /// Current command of the parser, this accumulates. - command: Command = undefined, - - /// Buffer that stores the input we see for a single OSC command. - /// Slices in Command are offsets into this buffer. - buf: [MAX_BUF]u8 = undefined, - buf_start: usize = 0, - buf_idx: usize = 0, - buf_dynamic: ?*std.ArrayListUnmanaged(u8) = null, - - /// True when a command is complete/valid to return. - complete: bool = false, - - /// Temporary state that is dependent on the current state. - temp_state: union { - /// Current string parameter being populated - str: *[]const u8, - - /// Current numeric parameter being populated - num: u16, - - /// Temporary state for key/value pairs - key: []const u8, - } = undefined, - - // Maximum length of a single OSC command. This is the full OSC command - // sequence length (excluding ESC ]). This is arbitrary, I couldn't find - // any definitive resource on how long this should be. - const MAX_BUF = 2048; - - pub const State = enum { - empty, - invalid, - - // Command prefixes. We could just accumulate and compare (mem.eql) - // but the state space is small enough that we just build it up this way. - @"0", - @"1", - @"10", - @"11", - @"12", - @"13", - @"133", - @"2", - @"22", - @"4", - @"5", - @"52", - @"7", - @"77", - @"777", - @"9", - - // OSC 10 is used to query or set the current foreground color. - query_fg_color, - - // OSC 11 is used to query or set the current background color. - query_bg_color, - - // OSC 12 is used to query or set the current cursor color. - query_cursor_color, - - // We're in a semantic prompt OSC command but we aren't sure - // what the command is yet, i.e. `133;` - semantic_prompt, - semantic_option_start, - semantic_option_key, - semantic_option_value, - semantic_exit_code_start, - semantic_exit_code, - - // Get/set clipboard states - clipboard_kind, - clipboard_kind_end, - - // Get/set color palette index - color_palette_index, - color_palette_index_end, - - // Reset color palette index - reset_color_palette_index, - - // rxvt extension. Only used for OSC 777 and only the value "notify" is - // supported - rxvt_extension, - - // Title of a desktop notification - notification_title, - - // Expect a string parameter. param_str must be set as well as - // buf_start. - string, - - // A string that can grow beyond MAX_BUF. This uses the allocator. - // If the parser has no allocator then it is treated as if the - // buffer is full. - allocable_string, - }; - - /// This must be called to clean up any allocated memory. - pub fn deinit(self: *Parser) void { - self.reset(); - } - - /// Reset the parser start. - pub fn reset(self: *Parser) void { - self.state = .empty; - self.buf_start = 0; - self.buf_idx = 0; - self.complete = false; - if (self.buf_dynamic) |ptr| { - const alloc = self.alloc.?; - ptr.deinit(alloc); - alloc.destroy(ptr); - self.buf_dynamic = null; - } - } - - /// Consume the next character c and advance the parser state. - pub fn next(self: *Parser, c: u8) void { - // If our buffer is full then we're invalid. - if (self.buf_idx >= self.buf.len) { - self.state = .invalid; - return; - } - - // We store everything in the buffer so we can do a better job - // logging if we get to an invalid command. - self.buf[self.buf_idx] = c; - self.buf_idx += 1; - - // log.warn("state = {} c = {x}", .{ self.state, c }); - - switch (self.state) { - // If we get something during the invalid state, we've - // ruined our entry. - .invalid => self.complete = false, - - .empty => switch (c) { - '0' => self.state = .@"0", - '1' => self.state = .@"1", - '2' => self.state = .@"2", - '4' => self.state = .@"4", - '5' => self.state = .@"5", - '7' => self.state = .@"7", - '9' => self.state = .@"9", - else => self.state = .invalid, - }, - - .@"0" => switch (c) { - ';' => { - self.command = .{ .change_window_title = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_title }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .@"1" => switch (c) { - ';' => { - self.command = .{ .change_window_icon = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_icon }; - self.buf_start = self.buf_idx; - }, - '0' => self.state = .@"10", - '1' => self.state = .@"11", - '2' => self.state = .@"12", - '3' => self.state = .@"13", - else => self.state = .invalid, - }, - - .@"10" => switch (c) { - ';' => self.state = .query_fg_color, - '4' => { - self.command = .{ .reset_color = .{ - .kind = .{ .palette = 0 }, - .value = "", - } }; - - self.state = .reset_color_palette_index; - self.complete = true; - }, - else => self.state = .invalid, - }, - - .@"11" => switch (c) { - ';' => self.state = .query_bg_color, - '0' => { - self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; - self.complete = true; - self.state = .invalid; - }, - '1' => { - self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; - self.complete = true; - self.state = .invalid; - }, - '2' => { - self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; - self.complete = true; - self.state = .invalid; - }, - else => self.state = .invalid, - }, - - .@"12" => switch (c) { - ';' => self.state = .query_cursor_color, - else => self.state = .invalid, - }, - - .@"13" => switch (c) { - '3' => self.state = .@"133", - else => self.state = .invalid, - }, - - .@"133" => switch (c) { - ';' => self.state = .semantic_prompt, - else => self.state = .invalid, - }, - - .@"2" => switch (c) { - '2' => self.state = .@"22", - ';' => { - self.command = .{ .change_window_title = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_title }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .@"22" => switch (c) { - ';' => { - self.command = .{ .mouse_shape = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.mouse_shape.value }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .@"4" => switch (c) { - ';' => { - self.state = .color_palette_index; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .color_palette_index => switch (c) { - '0'...'9' => {}, - ';' => blk: { - const str = self.buf[self.buf_start .. self.buf_idx - 1]; - if (str.len == 0) { - self.state = .invalid; - break :blk; - } - - if (std.fmt.parseUnsigned(u8, str, 10)) |num| { - self.state = .color_palette_index_end; - self.temp_state = .{ .num = num }; - } else |err| switch (err) { - error.Overflow => self.state = .invalid, - error.InvalidCharacter => unreachable, - } - }, - else => self.state = .invalid, - }, - - .color_palette_index_end => switch (c) { - '?' => { - self.command = .{ .report_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - } }; - - self.complete = true; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .reset_color_palette_index => switch (c) { - ';' => { - self.state = .string; - self.temp_state = .{ .str = &self.command.reset_color.value }; - self.buf_start = self.buf_idx; - self.complete = false; - }, - else => { - self.state = .invalid; - self.complete = false; - }, - }, - - .@"5" => switch (c) { - '2' => self.state = .@"52", - else => self.state = .invalid, - }, - - .@"52" => switch (c) { - ';' => { - self.command = .{ .clipboard_contents = undefined }; - self.state = .clipboard_kind; - }, - else => self.state = .invalid, - }, - - .clipboard_kind => switch (c) { - ';' => { - self.command.clipboard_contents.kind = 'c'; - self.temp_state = .{ .str = &self.command.clipboard_contents.data }; - self.buf_start = self.buf_idx; - self.prepAllocableString(); - }, - else => { - self.command.clipboard_contents.kind = c; - self.state = .clipboard_kind_end; - }, - }, - - .clipboard_kind_end => switch (c) { - ';' => { - self.temp_state = .{ .str = &self.command.clipboard_contents.data }; - self.buf_start = self.buf_idx; - self.prepAllocableString(); - }, - else => self.state = .invalid, - }, - - .@"7" => switch (c) { - ';' => { - self.command = .{ .report_pwd = .{ .value = "" } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.report_pwd.value }; - self.buf_start = self.buf_idx; - }, - '7' => self.state = .@"77", - else => self.state = .invalid, - }, - - .@"77" => switch (c) { - '7' => self.state = .@"777", - else => self.state = .invalid, - }, - - .@"777" => switch (c) { - ';' => { - self.state = .rxvt_extension; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .rxvt_extension => switch (c) { - 'a'...'z' => {}, - ';' => { - const ext = self.buf[self.buf_start .. self.buf_idx - 1]; - if (!std.mem.eql(u8, ext, "notify")) { - log.warn("unknown rxvt extension: {s}", .{ext}); - self.state = .invalid; - return; - } - - self.command = .{ .show_desktop_notification = undefined }; - self.buf_start = self.buf_idx; - self.state = .notification_title; - }, - else => self.state = .invalid, - }, - - .notification_title => switch (c) { - ';' => { - self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; - self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; - self.buf_start = self.buf_idx; - self.state = .string; - }, - else => {}, - }, - - .@"9" => switch (c) { - ';' => { - self.command = .{ .show_desktop_notification = .{ - .title = "", - .body = undefined, - } }; - - self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; - self.buf_start = self.buf_idx; - self.state = .string; - }, - else => self.state = .invalid, - }, - - .query_fg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .foreground } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .foreground, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_bg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .background } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .background, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_cursor_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .cursor } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .cursor, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .semantic_prompt => switch (c) { - 'A' => { - self.state = .semantic_option_start; - self.command = .{ .prompt_start = .{} }; - self.complete = true; - }, - - 'B' => { - self.state = .semantic_option_start; - self.command = .{ .prompt_end = {} }; - self.complete = true; - }, - - 'C' => { - self.state = .semantic_option_start; - self.command = .{ .end_of_input = {} }; - self.complete = true; - }, - - 'D' => { - self.state = .semantic_exit_code_start; - self.command = .{ .end_of_command = .{} }; - self.complete = true; - }, - - else => self.state = .invalid, - }, - - .semantic_option_start => switch (c) { - ';' => { - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .semantic_option_key => switch (c) { - '=' => { - self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; - self.state = .semantic_option_value; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .semantic_option_value => switch (c) { - ';' => { - self.endSemanticOptionValue(); - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .semantic_exit_code_start => switch (c) { - ';' => { - // No longer complete, if ';' shows up we expect some code. - self.complete = false; - self.state = .semantic_exit_code; - self.temp_state = .{ .num = 0 }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .semantic_exit_code => switch (c) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { - self.complete = true; - - const idx = self.buf_idx - self.buf_start; - if (idx > 0) self.temp_state.num *|= 10; - self.temp_state.num +|= c - '0'; - }, - ';' => { - self.endSemanticExitCode(); - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .allocable_string => { - const alloc = self.alloc.?; - const list = self.buf_dynamic.?; - list.append(alloc, c) catch { - self.state = .invalid; - return; - }; - - // Never consume buffer space for allocable strings - self.buf_idx -= 1; - - // We can complete at any time - self.complete = true; - }, - - .string => self.complete = true, - } - } - - fn prepAllocableString(self: *Parser) void { - assert(self.buf_dynamic == null); - - // We need an allocator. If we don't have an allocator, we - // pretend we're just a fixed buffer string and hope we fit! - const alloc = self.alloc orelse { - self.state = .string; - return; - }; - - // Allocate our dynamic buffer - const list = alloc.create(std.ArrayListUnmanaged(u8)) catch { - self.state = .string; - return; - }; - list.* = .{}; - - self.buf_dynamic = list; - self.state = .allocable_string; - } - - fn endSemanticOptionValue(self: *Parser) void { - const value = self.buf[self.buf_start..self.buf_idx]; - - if (mem.eql(u8, self.temp_state.key, "aid")) { - switch (self.command) { - .prompt_start => |*v| v.aid = value, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "redraw")) { - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. - switch (self.command) { - .prompt_start => |*v| { - const valid = if (value.len == 1) valid: { - switch (value[0]) { - '0' => v.redraw = false, - '1' => v.redraw = true, - else => break :valid false, - } - - break :valid true; - } else false; - - if (!valid) { - log.info("OSC 133 A invalid redraw value: {s}", .{value}); - } - }, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "k")) { - // The "k" marks the kind of prompt, or "primary" if we don't know. - // This can be used to distinguish between the first prompt, - // a continuation, etc. - switch (self.command) { - .prompt_start => |*v| if (value.len == 1) { - v.kind = switch (value[0]) { - 'c', 's' => .continuation, - 'r' => .right, - 'i' => .primary, - else => .primary, - }; - }, - else => {}, - } - } else log.info("unknown semantic prompts option: {s}", .{self.temp_state.key}); - } - - fn endSemanticExitCode(self: *Parser) void { - switch (self.command) { - .end_of_command => |*v| v.exit_code = @truncate(self.temp_state.num), - else => {}, - } - } - - fn endString(self: *Parser) void { - self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; - } - - fn endAllocableString(self: *Parser) void { - const list = self.buf_dynamic.?; - self.temp_state.str.* = list.items; - } - - /// End the sequence and return the command, if any. If the return value - /// is null, then no valid command was found. The optional terminator_ch - /// is the final character in the OSC sequence. This is used to determine - /// the response terminator. - pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { - if (!self.complete) { - log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); - return null; - } - - // Other cleanup we may have to do depending on state. - switch (self.state) { - .semantic_exit_code => self.endSemanticExitCode(), - .semantic_option_value => self.endSemanticOptionValue(), - .string => self.endString(), - .allocable_string => self.endAllocableString(), - else => {}, - } - - switch (self.command) { - .report_color => |*c| c.terminator = Terminator.init(terminator_ch), - else => {}, - } - - return self.command; - } -}; - -test "OSC: change_window_title" { - const testing = std.testing; - - var p: Parser = .{}; - p.next('0'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("ab", cmd.change_window_title); -} - -test "OSC: change_window_title with 2" { - const testing = std.testing; - - var p: Parser = .{}; - p.next('2'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("ab", cmd.change_window_title); -} - -test "OSC: change_window_title with utf8" { - const testing = std.testing; - - var p: Parser = .{}; - p.next('2'); - p.next(';'); - // '—' EM DASH U+2014 (E2 80 94) - p.next(0xE2); - p.next(0x80); - p.next(0x94); - - p.next(' '); - // '‐' HYPHEN U+2010 (E2 80 90) - // Intententionally chosen to conflict with the 0x90 C1 control - p.next(0xE2); - p.next(0x80); - p.next(0x90); - const cmd = p.end(null).?; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("— ‐", cmd.change_window_title); -} - -test "OSC: change_window_icon" { - const testing = std.testing; - - var p: Parser = .{}; - p.next('1'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?; - try testing.expect(cmd == .change_window_icon); - try testing.expectEqualStrings("ab", cmd.change_window_icon); -} - -test "OSC: prompt_start" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;A"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expect(cmd.prompt_start.redraw); -} - -test "OSC: prompt_start with single option" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;A;aid=14"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); -} - -test "OSC: prompt_start with redraw disabled" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;A;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_start); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC: prompt_start with redraw invalid value" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;A;redraw=42"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.redraw); - try testing.expect(cmd.prompt_start.kind == .primary); -} - -test "OSC: prompt_start with continuation" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;A;k=c"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .continuation); -} - -test "OSC: end_of_command no exit code" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;D"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .end_of_command); -} - -test "OSC: end_of_command with exit code" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;D;25"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .end_of_command); - try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); -} - -test "OSC: prompt_end" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;B"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .prompt_end); -} - -test "OSC: end_of_input" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "133;C"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .end_of_input); -} - -test "OSC: reset cursor color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "112"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); -} - -test "OSC: get/set clipboard" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); -} - -test "OSC: get/set clipboard (optional parameter)" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "52;;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); -} - -test "OSC: get/set clipboard with allocator" { - const testing = std.testing; - const alloc = testing.allocator; - - var p: Parser = .{ .alloc = alloc }; - defer p.deinit(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); -} - -test "OSC: report pwd" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "7;file:///tmp/example"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .report_pwd); - try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); -} - -test "OSC: pointer cursor" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "22;pointer"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?; - try testing.expect(cmd == .mouse_shape); - try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); -} - -test "OSC: report pwd empty" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "7;"; - for (input) |ch| p.next(ch); - - try testing.expect(p.end(null) == null); -} - -test "OSC: longer than buffer" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "a" ** (Parser.MAX_BUF + 2); - for (input) |ch| p.next(ch); - - try testing.expect(p.end(null) == null); -} - -test "OSC: report default foreground color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "10;?"; - for (input) |ch| p.next(ch); - - // This corresponds to ST = ESC followed by \ - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .foreground); - try testing.expectEqual(cmd.report_color.terminator, .st); -} - -test "OSC: set foreground color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "10;rgbi:0.0/0.5/1.0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x07').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .foreground); - try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); -} - -test "OSC: report default background color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "11;?"; - for (input) |ch| p.next(ch); - - // This corresponds to ST = BEL character - const cmd = p.end('\x07').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .background); - try testing.expectEqual(cmd.report_color.terminator, .bel); -} - -test "OSC: set background color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "11;rgb:f/ff/ffff"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .background); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); -} - -test "OSC: get palette color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "4;1;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); - try testing.expectEqual(cmd.report_color.terminator, .st); -} - -test "OSC: set palette color" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "4;17;rgb:aa/bb/cc"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); -} - -test "OSC: show desktop notification" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "9;Hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world"); -} - -test "OSC: show desktop notification with title" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "777;notify;Title;Body"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); -} - -test "OSC: empty param" { - const testing = std.testing; - - var p: Parser = .{}; - - const input = "4;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); -} diff --git a/src/terminal-old/parse_table.zig b/src/terminal-old/parse_table.zig deleted file mode 100644 index 66c443783c..0000000000 --- a/src/terminal-old/parse_table.zig +++ /dev/null @@ -1,389 +0,0 @@ -//! The primary export of this file is "table", which contains a -//! comptime-generated state transition table for VT emulation. -//! -//! This is based on the vt100.net state machine: -//! https://vt100.net/emu/dec_ansi_parser -//! But has some modifications: -//! -//! * csi_param accepts the colon character (':') since the SGR command -//! accepts colon as a valid parameter value. -//! - -const std = @import("std"); -const builtin = @import("builtin"); -const parser = @import("Parser.zig"); -const State = parser.State; -const Action = parser.TransitionAction; - -/// The state transition table. The type is [u8][State]Transition but -/// comptime-generated to be exactly-sized. -pub const table = genTable(); - -/// Table is the type of the state table. This is dynamically (comptime) -/// generated to be exactly sized. -pub const Table = genTableType(false); - -/// OptionalTable is private to this file. We use this to accumulate and -/// detect invalid transitions created. -const OptionalTable = genTableType(true); - -// Transition is the transition to take within the table -pub const Transition = struct { - state: State, - action: Action, -}; - -/// Table is the type of the state transition table. -fn genTableType(comptime optional: bool) type { - const max_u8 = std.math.maxInt(u8); - const stateInfo = @typeInfo(State); - const max_state = stateInfo.Enum.fields.len; - const Elem = if (optional) ?Transition else Transition; - return [max_u8 + 1][max_state]Elem; -} - -/// Function to generate the full state transition table for VT emulation. -fn genTable() Table { - @setEvalBranchQuota(20000); - - // We accumulate using an "optional" table so we can detect duplicates. - var result: OptionalTable = undefined; - for (0..result.len) |i| { - for (0..result[0].len) |j| { - result[i][j] = null; - } - } - - // anywhere transitions - const stateInfo = @typeInfo(State); - inline for (stateInfo.Enum.fields) |field| { - const source: State = @enumFromInt(field.value); - - // anywhere => ground - single(&result, 0x18, source, .ground, .execute); - single(&result, 0x1A, source, .ground, .execute); - range(&result, 0x80, 0x8F, source, .ground, .execute); - range(&result, 0x91, 0x97, source, .ground, .execute); - single(&result, 0x99, source, .ground, .execute); - single(&result, 0x9A, source, .ground, .execute); - single(&result, 0x9C, source, .ground, .none); - - // anywhere => escape - single(&result, 0x1B, source, .escape, .none); - - // anywhere => sos_pm_apc_string - single(&result, 0x98, source, .sos_pm_apc_string, .none); - single(&result, 0x9E, source, .sos_pm_apc_string, .none); - single(&result, 0x9F, source, .sos_pm_apc_string, .none); - - // anywhere => csi_entry - single(&result, 0x9B, source, .csi_entry, .none); - - // anywhere => dcs_entry - single(&result, 0x90, source, .dcs_entry, .none); - - // anywhere => osc_string - single(&result, 0x9D, source, .osc_string, .none); - } - - // ground - { - // events - single(&result, 0x19, .ground, .ground, .execute); - range(&result, 0, 0x17, .ground, .ground, .execute); - range(&result, 0x1C, 0x1F, .ground, .ground, .execute); - range(&result, 0x20, 0x7F, .ground, .ground, .print); - } - - // escape_intermediate - { - const source = State.escape_intermediate; - - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - range(&result, 0x20, 0x2F, source, source, .collect); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x30, 0x7E, source, .ground, .esc_dispatch); - } - - // sos_pm_apc_string - { - const source = State.sos_pm_apc_string; - - // events - single(&result, 0x19, source, source, .apc_put); - range(&result, 0, 0x17, source, source, .apc_put); - range(&result, 0x1C, 0x1F, source, source, .apc_put); - range(&result, 0x20, 0x7F, source, source, .apc_put); - } - - // escape - { - const source = State.escape; - - // events - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x30, 0x4F, source, .ground, .esc_dispatch); - range(&result, 0x51, 0x57, source, .ground, .esc_dispatch); - range(&result, 0x60, 0x7E, source, .ground, .esc_dispatch); - single(&result, 0x59, source, .ground, .esc_dispatch); - single(&result, 0x5A, source, .ground, .esc_dispatch); - single(&result, 0x5C, source, .ground, .esc_dispatch); - - // => escape_intermediate - range(&result, 0x20, 0x2F, source, .escape_intermediate, .collect); - - // => sos_pm_apc_string - single(&result, 0x58, source, .sos_pm_apc_string, .none); - single(&result, 0x5E, source, .sos_pm_apc_string, .none); - single(&result, 0x5F, source, .sos_pm_apc_string, .none); - - // => dcs_entry - single(&result, 0x50, source, .dcs_entry, .none); - - // => csi_entry - single(&result, 0x5B, source, .csi_entry, .none); - - // => osc_string - single(&result, 0x5D, source, .osc_string, .none); - } - - // dcs_entry - { - const source = State.dcs_entry; - - // events - single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x17, source, source, .ignore); - range(&result, 0x1C, 0x1F, source, source, .ignore); - single(&result, 0x7F, source, source, .ignore); - - // => dcs_intermediate - range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); - - // => dcs_ignore - single(&result, 0x3A, source, .dcs_ignore, .none); - - // => dcs_param - range(&result, 0x30, 0x39, source, .dcs_param, .param); - single(&result, 0x3B, source, .dcs_param, .param); - range(&result, 0x3C, 0x3F, source, .dcs_param, .collect); - - // => dcs_passthrough - range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); - } - - // dcs_intermediate - { - const source = State.dcs_intermediate; - - // events - single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x17, source, source, .ignore); - range(&result, 0x1C, 0x1F, source, source, .ignore); - range(&result, 0x20, 0x2F, source, source, .collect); - single(&result, 0x7F, source, source, .ignore); - - // => dcs_ignore - range(&result, 0x30, 0x3F, source, .dcs_ignore, .none); - - // => dcs_passthrough - range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); - } - - // dcs_ignore - { - const source = State.dcs_ignore; - - // events - single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x17, source, source, .ignore); - range(&result, 0x1C, 0x1F, source, source, .ignore); - } - - // dcs_param - { - const source = State.dcs_param; - - // events - single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x17, source, source, .ignore); - range(&result, 0x1C, 0x1F, source, source, .ignore); - range(&result, 0x30, 0x39, source, source, .param); - single(&result, 0x3B, source, source, .param); - single(&result, 0x7F, source, source, .ignore); - - // => dcs_ignore - single(&result, 0x3A, source, .dcs_ignore, .none); - range(&result, 0x3C, 0x3F, source, .dcs_ignore, .none); - - // => dcs_intermediate - range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); - - // => dcs_passthrough - range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); - } - - // dcs_passthrough - { - const source = State.dcs_passthrough; - - // events - single(&result, 0x19, source, source, .put); - range(&result, 0, 0x17, source, source, .put); - range(&result, 0x1C, 0x1F, source, source, .put); - range(&result, 0x20, 0x7E, source, source, .put); - single(&result, 0x7F, source, source, .ignore); - } - - // csi_param - { - const source = State.csi_param; - - // events - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - range(&result, 0x30, 0x39, source, source, .param); - single(&result, 0x3A, source, source, .param); - single(&result, 0x3B, source, source, .param); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); - - // => csi_ignore - range(&result, 0x3C, 0x3F, source, .csi_ignore, .none); - - // => csi_intermediate - range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); - } - - // csi_ignore - { - const source = State.csi_ignore; - - // events - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - range(&result, 0x20, 0x3F, source, source, .ignore); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x40, 0x7E, source, .ground, .none); - } - - // csi_intermediate - { - const source = State.csi_intermediate; - - // events - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - range(&result, 0x20, 0x2F, source, source, .collect); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); - - // => csi_ignore - range(&result, 0x30, 0x3F, source, .csi_ignore, .none); - } - - // csi_entry - { - const source = State.csi_entry; - - // events - single(&result, 0x19, source, source, .execute); - range(&result, 0, 0x17, source, source, .execute); - range(&result, 0x1C, 0x1F, source, source, .execute); - single(&result, 0x7F, source, source, .ignore); - - // => ground - range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); - - // => csi_ignore - single(&result, 0x3A, source, .csi_ignore, .none); - - // => csi_intermediate - range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); - - // => csi_param - range(&result, 0x30, 0x39, source, .csi_param, .param); - single(&result, 0x3B, source, .csi_param, .param); - range(&result, 0x3C, 0x3F, source, .csi_param, .collect); - } - - // osc_string - { - const source = State.osc_string; - - // events - single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x06, source, source, .ignore); - range(&result, 0x08, 0x17, source, source, .ignore); - range(&result, 0x1C, 0x1F, source, source, .ignore); - range(&result, 0x20, 0xFF, source, source, .osc_put); - - // XTerm accepts either BEL or ST for terminating OSC - // sequences, and when returning information, uses the same - // terminator used in a query. - single(&result, 0x07, source, .ground, .none); - } - - // Create our immutable version - var final: Table = undefined; - for (0..final.len) |i| { - for (0..final[0].len) |j| { - final[i][j] = result[i][j] orelse transition(@enumFromInt(j), .none); - } - } - - return final; -} - -fn single(t: *OptionalTable, c: u8, s0: State, s1: State, a: Action) void { - const s0_int = @intFromEnum(s0); - - // TODO: enable this but it thinks we're in runtime right now - // if (t[c][s0_int]) |existing| { - // @compileLog(c); - // @compileLog(s0); - // @compileLog(s1); - // @compileLog(existing); - // @compileError("transition set multiple times"); - // } - - t[c][s0_int] = transition(s1, a); -} - -fn range(t: *OptionalTable, from: u8, to: u8, s0: State, s1: State, a: Action) void { - var i = from; - while (i <= to) : (i += 1) { - single(t, i, s0, s1, a); - // If 'to' is 0xFF, our next pass will overflow. Return early to prevent - // the loop from executing it's continue expression - if (i == to) break; - } -} - -fn transition(state: State, action: Action) Transition { - return .{ .state = state, .action = action }; -} - -test { - // This forces comptime-evaluation of table, so we're just testing - // that it succeeds in creation. - _ = table; -} diff --git a/src/terminal-old/point.zig b/src/terminal-old/point.zig deleted file mode 100644 index 8c694f992c..0000000000 --- a/src/terminal-old/point.zig +++ /dev/null @@ -1,254 +0,0 @@ -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/terminal-old/res/rgb.txt b/src/terminal-old/res/rgb.txt deleted file mode 100644 index 7096643760..0000000000 --- a/src/terminal-old/res/rgb.txt +++ /dev/null @@ -1,782 +0,0 @@ -255 250 250 snow -248 248 255 ghost white -248 248 255 GhostWhite -245 245 245 white smoke -245 245 245 WhiteSmoke -220 220 220 gainsboro -255 250 240 floral white -255 250 240 FloralWhite -253 245 230 old lace -253 245 230 OldLace -250 240 230 linen -250 235 215 antique white -250 235 215 AntiqueWhite -255 239 213 papaya whip -255 239 213 PapayaWhip -255 235 205 blanched almond -255 235 205 BlanchedAlmond -255 228 196 bisque -255 218 185 peach puff -255 218 185 PeachPuff -255 222 173 navajo white -255 222 173 NavajoWhite -255 228 181 moccasin -255 248 220 cornsilk -255 255 240 ivory -255 250 205 lemon chiffon -255 250 205 LemonChiffon -255 245 238 seashell -240 255 240 honeydew -245 255 250 mint cream -245 255 250 MintCream -240 255 255 azure -240 248 255 alice blue -240 248 255 AliceBlue -230 230 250 lavender -255 240 245 lavender blush -255 240 245 LavenderBlush -255 228 225 misty rose -255 228 225 MistyRose -255 255 255 white - 0 0 0 black - 47 79 79 dark slate gray - 47 79 79 DarkSlateGray - 47 79 79 dark slate grey - 47 79 79 DarkSlateGrey -105 105 105 dim gray -105 105 105 DimGray -105 105 105 dim grey -105 105 105 DimGrey -112 128 144 slate gray -112 128 144 SlateGray -112 128 144 slate grey -112 128 144 SlateGrey -119 136 153 light slate gray -119 136 153 LightSlateGray -119 136 153 light slate grey -119 136 153 LightSlateGrey -190 190 190 gray -190 190 190 grey -190 190 190 x11 gray -190 190 190 X11Gray -190 190 190 x11 grey -190 190 190 X11Grey -128 128 128 web gray -128 128 128 WebGray -128 128 128 web grey -128 128 128 WebGrey -211 211 211 light grey -211 211 211 LightGrey -211 211 211 light gray -211 211 211 LightGray - 25 25 112 midnight blue - 25 25 112 MidnightBlue - 0 0 128 navy - 0 0 128 navy blue - 0 0 128 NavyBlue -100 149 237 cornflower blue -100 149 237 CornflowerBlue - 72 61 139 dark slate blue - 72 61 139 DarkSlateBlue -106 90 205 slate blue -106 90 205 SlateBlue -123 104 238 medium slate blue -123 104 238 MediumSlateBlue -132 112 255 light slate blue -132 112 255 LightSlateBlue - 0 0 205 medium blue - 0 0 205 MediumBlue - 65 105 225 royal blue - 65 105 225 RoyalBlue - 0 0 255 blue - 30 144 255 dodger blue - 30 144 255 DodgerBlue - 0 191 255 deep sky blue - 0 191 255 DeepSkyBlue -135 206 235 sky blue -135 206 235 SkyBlue -135 206 250 light sky blue -135 206 250 LightSkyBlue - 70 130 180 steel blue - 70 130 180 SteelBlue -176 196 222 light steel blue -176 196 222 LightSteelBlue -173 216 230 light blue -173 216 230 LightBlue -176 224 230 powder blue -176 224 230 PowderBlue -175 238 238 pale turquoise -175 238 238 PaleTurquoise - 0 206 209 dark turquoise - 0 206 209 DarkTurquoise - 72 209 204 medium turquoise - 72 209 204 MediumTurquoise - 64 224 208 turquoise - 0 255 255 cyan - 0 255 255 aqua -224 255 255 light cyan -224 255 255 LightCyan - 95 158 160 cadet blue - 95 158 160 CadetBlue -102 205 170 medium aquamarine -102 205 170 MediumAquamarine -127 255 212 aquamarine - 0 100 0 dark green - 0 100 0 DarkGreen - 85 107 47 dark olive green - 85 107 47 DarkOliveGreen -143 188 143 dark sea green -143 188 143 DarkSeaGreen - 46 139 87 sea green - 46 139 87 SeaGreen - 60 179 113 medium sea green - 60 179 113 MediumSeaGreen - 32 178 170 light sea green - 32 178 170 LightSeaGreen -152 251 152 pale green -152 251 152 PaleGreen - 0 255 127 spring green - 0 255 127 SpringGreen -124 252 0 lawn green -124 252 0 LawnGreen - 0 255 0 green - 0 255 0 lime - 0 255 0 x11 green - 0 255 0 X11Green - 0 128 0 web green - 0 128 0 WebGreen -127 255 0 chartreuse - 0 250 154 medium spring green - 0 250 154 MediumSpringGreen -173 255 47 green yellow -173 255 47 GreenYellow - 50 205 50 lime green - 50 205 50 LimeGreen -154 205 50 yellow green -154 205 50 YellowGreen - 34 139 34 forest green - 34 139 34 ForestGreen -107 142 35 olive drab -107 142 35 OliveDrab -189 183 107 dark khaki -189 183 107 DarkKhaki -240 230 140 khaki -238 232 170 pale goldenrod -238 232 170 PaleGoldenrod -250 250 210 light goldenrod yellow -250 250 210 LightGoldenrodYellow -255 255 224 light yellow -255 255 224 LightYellow -255 255 0 yellow -255 215 0 gold -238 221 130 light goldenrod -238 221 130 LightGoldenrod -218 165 32 goldenrod -184 134 11 dark goldenrod -184 134 11 DarkGoldenrod -188 143 143 rosy brown -188 143 143 RosyBrown -205 92 92 indian red -205 92 92 IndianRed -139 69 19 saddle brown -139 69 19 SaddleBrown -160 82 45 sienna -205 133 63 peru -222 184 135 burlywood -245 245 220 beige -245 222 179 wheat -244 164 96 sandy brown -244 164 96 SandyBrown -210 180 140 tan -210 105 30 chocolate -178 34 34 firebrick -165 42 42 brown -233 150 122 dark salmon -233 150 122 DarkSalmon -250 128 114 salmon -255 160 122 light salmon -255 160 122 LightSalmon -255 165 0 orange -255 140 0 dark orange -255 140 0 DarkOrange -255 127 80 coral -240 128 128 light coral -240 128 128 LightCoral -255 99 71 tomato -255 69 0 orange red -255 69 0 OrangeRed -255 0 0 red -255 105 180 hot pink -255 105 180 HotPink -255 20 147 deep pink -255 20 147 DeepPink -255 192 203 pink -255 182 193 light pink -255 182 193 LightPink -219 112 147 pale violet red -219 112 147 PaleVioletRed -176 48 96 maroon -176 48 96 x11 maroon -176 48 96 X11Maroon -128 0 0 web maroon -128 0 0 WebMaroon -199 21 133 medium violet red -199 21 133 MediumVioletRed -208 32 144 violet red -208 32 144 VioletRed -255 0 255 magenta -255 0 255 fuchsia -238 130 238 violet -221 160 221 plum -218 112 214 orchid -186 85 211 medium orchid -186 85 211 MediumOrchid -153 50 204 dark orchid -153 50 204 DarkOrchid -148 0 211 dark violet -148 0 211 DarkViolet -138 43 226 blue violet -138 43 226 BlueViolet -160 32 240 purple -160 32 240 x11 purple -160 32 240 X11Purple -128 0 128 web purple -128 0 128 WebPurple -147 112 219 medium purple -147 112 219 MediumPurple -216 191 216 thistle -255 250 250 snow1 -238 233 233 snow2 -205 201 201 snow3 -139 137 137 snow4 -255 245 238 seashell1 -238 229 222 seashell2 -205 197 191 seashell3 -139 134 130 seashell4 -255 239 219 AntiqueWhite1 -238 223 204 AntiqueWhite2 -205 192 176 AntiqueWhite3 -139 131 120 AntiqueWhite4 -255 228 196 bisque1 -238 213 183 bisque2 -205 183 158 bisque3 -139 125 107 bisque4 -255 218 185 PeachPuff1 -238 203 173 PeachPuff2 -205 175 149 PeachPuff3 -139 119 101 PeachPuff4 -255 222 173 NavajoWhite1 -238 207 161 NavajoWhite2 -205 179 139 NavajoWhite3 -139 121 94 NavajoWhite4 -255 250 205 LemonChiffon1 -238 233 191 LemonChiffon2 -205 201 165 LemonChiffon3 -139 137 112 LemonChiffon4 -255 248 220 cornsilk1 -238 232 205 cornsilk2 -205 200 177 cornsilk3 -139 136 120 cornsilk4 -255 255 240 ivory1 -238 238 224 ivory2 -205 205 193 ivory3 -139 139 131 ivory4 -240 255 240 honeydew1 -224 238 224 honeydew2 -193 205 193 honeydew3 -131 139 131 honeydew4 -255 240 245 LavenderBlush1 -238 224 229 LavenderBlush2 -205 193 197 LavenderBlush3 -139 131 134 LavenderBlush4 -255 228 225 MistyRose1 -238 213 210 MistyRose2 -205 183 181 MistyRose3 -139 125 123 MistyRose4 -240 255 255 azure1 -224 238 238 azure2 -193 205 205 azure3 -131 139 139 azure4 -131 111 255 SlateBlue1 -122 103 238 SlateBlue2 -105 89 205 SlateBlue3 - 71 60 139 SlateBlue4 - 72 118 255 RoyalBlue1 - 67 110 238 RoyalBlue2 - 58 95 205 RoyalBlue3 - 39 64 139 RoyalBlue4 - 0 0 255 blue1 - 0 0 238 blue2 - 0 0 205 blue3 - 0 0 139 blue4 - 30 144 255 DodgerBlue1 - 28 134 238 DodgerBlue2 - 24 116 205 DodgerBlue3 - 16 78 139 DodgerBlue4 - 99 184 255 SteelBlue1 - 92 172 238 SteelBlue2 - 79 148 205 SteelBlue3 - 54 100 139 SteelBlue4 - 0 191 255 DeepSkyBlue1 - 0 178 238 DeepSkyBlue2 - 0 154 205 DeepSkyBlue3 - 0 104 139 DeepSkyBlue4 -135 206 255 SkyBlue1 -126 192 238 SkyBlue2 -108 166 205 SkyBlue3 - 74 112 139 SkyBlue4 -176 226 255 LightSkyBlue1 -164 211 238 LightSkyBlue2 -141 182 205 LightSkyBlue3 - 96 123 139 LightSkyBlue4 -198 226 255 SlateGray1 -185 211 238 SlateGray2 -159 182 205 SlateGray3 -108 123 139 SlateGray4 -202 225 255 LightSteelBlue1 -188 210 238 LightSteelBlue2 -162 181 205 LightSteelBlue3 -110 123 139 LightSteelBlue4 -191 239 255 LightBlue1 -178 223 238 LightBlue2 -154 192 205 LightBlue3 -104 131 139 LightBlue4 -224 255 255 LightCyan1 -209 238 238 LightCyan2 -180 205 205 LightCyan3 -122 139 139 LightCyan4 -187 255 255 PaleTurquoise1 -174 238 238 PaleTurquoise2 -150 205 205 PaleTurquoise3 -102 139 139 PaleTurquoise4 -152 245 255 CadetBlue1 -142 229 238 CadetBlue2 -122 197 205 CadetBlue3 - 83 134 139 CadetBlue4 - 0 245 255 turquoise1 - 0 229 238 turquoise2 - 0 197 205 turquoise3 - 0 134 139 turquoise4 - 0 255 255 cyan1 - 0 238 238 cyan2 - 0 205 205 cyan3 - 0 139 139 cyan4 -151 255 255 DarkSlateGray1 -141 238 238 DarkSlateGray2 -121 205 205 DarkSlateGray3 - 82 139 139 DarkSlateGray4 -127 255 212 aquamarine1 -118 238 198 aquamarine2 -102 205 170 aquamarine3 - 69 139 116 aquamarine4 -193 255 193 DarkSeaGreen1 -180 238 180 DarkSeaGreen2 -155 205 155 DarkSeaGreen3 -105 139 105 DarkSeaGreen4 - 84 255 159 SeaGreen1 - 78 238 148 SeaGreen2 - 67 205 128 SeaGreen3 - 46 139 87 SeaGreen4 -154 255 154 PaleGreen1 -144 238 144 PaleGreen2 -124 205 124 PaleGreen3 - 84 139 84 PaleGreen4 - 0 255 127 SpringGreen1 - 0 238 118 SpringGreen2 - 0 205 102 SpringGreen3 - 0 139 69 SpringGreen4 - 0 255 0 green1 - 0 238 0 green2 - 0 205 0 green3 - 0 139 0 green4 -127 255 0 chartreuse1 -118 238 0 chartreuse2 -102 205 0 chartreuse3 - 69 139 0 chartreuse4 -192 255 62 OliveDrab1 -179 238 58 OliveDrab2 -154 205 50 OliveDrab3 -105 139 34 OliveDrab4 -202 255 112 DarkOliveGreen1 -188 238 104 DarkOliveGreen2 -162 205 90 DarkOliveGreen3 -110 139 61 DarkOliveGreen4 -255 246 143 khaki1 -238 230 133 khaki2 -205 198 115 khaki3 -139 134 78 khaki4 -255 236 139 LightGoldenrod1 -238 220 130 LightGoldenrod2 -205 190 112 LightGoldenrod3 -139 129 76 LightGoldenrod4 -255 255 224 LightYellow1 -238 238 209 LightYellow2 -205 205 180 LightYellow3 -139 139 122 LightYellow4 -255 255 0 yellow1 -238 238 0 yellow2 -205 205 0 yellow3 -139 139 0 yellow4 -255 215 0 gold1 -238 201 0 gold2 -205 173 0 gold3 -139 117 0 gold4 -255 193 37 goldenrod1 -238 180 34 goldenrod2 -205 155 29 goldenrod3 -139 105 20 goldenrod4 -255 185 15 DarkGoldenrod1 -238 173 14 DarkGoldenrod2 -205 149 12 DarkGoldenrod3 -139 101 8 DarkGoldenrod4 -255 193 193 RosyBrown1 -238 180 180 RosyBrown2 -205 155 155 RosyBrown3 -139 105 105 RosyBrown4 -255 106 106 IndianRed1 -238 99 99 IndianRed2 -205 85 85 IndianRed3 -139 58 58 IndianRed4 -255 130 71 sienna1 -238 121 66 sienna2 -205 104 57 sienna3 -139 71 38 sienna4 -255 211 155 burlywood1 -238 197 145 burlywood2 -205 170 125 burlywood3 -139 115 85 burlywood4 -255 231 186 wheat1 -238 216 174 wheat2 -205 186 150 wheat3 -139 126 102 wheat4 -255 165 79 tan1 -238 154 73 tan2 -205 133 63 tan3 -139 90 43 tan4 -255 127 36 chocolate1 -238 118 33 chocolate2 -205 102 29 chocolate3 -139 69 19 chocolate4 -255 48 48 firebrick1 -238 44 44 firebrick2 -205 38 38 firebrick3 -139 26 26 firebrick4 -255 64 64 brown1 -238 59 59 brown2 -205 51 51 brown3 -139 35 35 brown4 -255 140 105 salmon1 -238 130 98 salmon2 -205 112 84 salmon3 -139 76 57 salmon4 -255 160 122 LightSalmon1 -238 149 114 LightSalmon2 -205 129 98 LightSalmon3 -139 87 66 LightSalmon4 -255 165 0 orange1 -238 154 0 orange2 -205 133 0 orange3 -139 90 0 orange4 -255 127 0 DarkOrange1 -238 118 0 DarkOrange2 -205 102 0 DarkOrange3 -139 69 0 DarkOrange4 -255 114 86 coral1 -238 106 80 coral2 -205 91 69 coral3 -139 62 47 coral4 -255 99 71 tomato1 -238 92 66 tomato2 -205 79 57 tomato3 -139 54 38 tomato4 -255 69 0 OrangeRed1 -238 64 0 OrangeRed2 -205 55 0 OrangeRed3 -139 37 0 OrangeRed4 -255 0 0 red1 -238 0 0 red2 -205 0 0 red3 -139 0 0 red4 -255 20 147 DeepPink1 -238 18 137 DeepPink2 -205 16 118 DeepPink3 -139 10 80 DeepPink4 -255 110 180 HotPink1 -238 106 167 HotPink2 -205 96 144 HotPink3 -139 58 98 HotPink4 -255 181 197 pink1 -238 169 184 pink2 -205 145 158 pink3 -139 99 108 pink4 -255 174 185 LightPink1 -238 162 173 LightPink2 -205 140 149 LightPink3 -139 95 101 LightPink4 -255 130 171 PaleVioletRed1 -238 121 159 PaleVioletRed2 -205 104 137 PaleVioletRed3 -139 71 93 PaleVioletRed4 -255 52 179 maroon1 -238 48 167 maroon2 -205 41 144 maroon3 -139 28 98 maroon4 -255 62 150 VioletRed1 -238 58 140 VioletRed2 -205 50 120 VioletRed3 -139 34 82 VioletRed4 -255 0 255 magenta1 -238 0 238 magenta2 -205 0 205 magenta3 -139 0 139 magenta4 -255 131 250 orchid1 -238 122 233 orchid2 -205 105 201 orchid3 -139 71 137 orchid4 -255 187 255 plum1 -238 174 238 plum2 -205 150 205 plum3 -139 102 139 plum4 -224 102 255 MediumOrchid1 -209 95 238 MediumOrchid2 -180 82 205 MediumOrchid3 -122 55 139 MediumOrchid4 -191 62 255 DarkOrchid1 -178 58 238 DarkOrchid2 -154 50 205 DarkOrchid3 -104 34 139 DarkOrchid4 -155 48 255 purple1 -145 44 238 purple2 -125 38 205 purple3 - 85 26 139 purple4 -171 130 255 MediumPurple1 -159 121 238 MediumPurple2 -137 104 205 MediumPurple3 - 93 71 139 MediumPurple4 -255 225 255 thistle1 -238 210 238 thistle2 -205 181 205 thistle3 -139 123 139 thistle4 - 0 0 0 gray0 - 0 0 0 grey0 - 3 3 3 gray1 - 3 3 3 grey1 - 5 5 5 gray2 - 5 5 5 grey2 - 8 8 8 gray3 - 8 8 8 grey3 - 10 10 10 gray4 - 10 10 10 grey4 - 13 13 13 gray5 - 13 13 13 grey5 - 15 15 15 gray6 - 15 15 15 grey6 - 18 18 18 gray7 - 18 18 18 grey7 - 20 20 20 gray8 - 20 20 20 grey8 - 23 23 23 gray9 - 23 23 23 grey9 - 26 26 26 gray10 - 26 26 26 grey10 - 28 28 28 gray11 - 28 28 28 grey11 - 31 31 31 gray12 - 31 31 31 grey12 - 33 33 33 gray13 - 33 33 33 grey13 - 36 36 36 gray14 - 36 36 36 grey14 - 38 38 38 gray15 - 38 38 38 grey15 - 41 41 41 gray16 - 41 41 41 grey16 - 43 43 43 gray17 - 43 43 43 grey17 - 46 46 46 gray18 - 46 46 46 grey18 - 48 48 48 gray19 - 48 48 48 grey19 - 51 51 51 gray20 - 51 51 51 grey20 - 54 54 54 gray21 - 54 54 54 grey21 - 56 56 56 gray22 - 56 56 56 grey22 - 59 59 59 gray23 - 59 59 59 grey23 - 61 61 61 gray24 - 61 61 61 grey24 - 64 64 64 gray25 - 64 64 64 grey25 - 66 66 66 gray26 - 66 66 66 grey26 - 69 69 69 gray27 - 69 69 69 grey27 - 71 71 71 gray28 - 71 71 71 grey28 - 74 74 74 gray29 - 74 74 74 grey29 - 77 77 77 gray30 - 77 77 77 grey30 - 79 79 79 gray31 - 79 79 79 grey31 - 82 82 82 gray32 - 82 82 82 grey32 - 84 84 84 gray33 - 84 84 84 grey33 - 87 87 87 gray34 - 87 87 87 grey34 - 89 89 89 gray35 - 89 89 89 grey35 - 92 92 92 gray36 - 92 92 92 grey36 - 94 94 94 gray37 - 94 94 94 grey37 - 97 97 97 gray38 - 97 97 97 grey38 - 99 99 99 gray39 - 99 99 99 grey39 -102 102 102 gray40 -102 102 102 grey40 -105 105 105 gray41 -105 105 105 grey41 -107 107 107 gray42 -107 107 107 grey42 -110 110 110 gray43 -110 110 110 grey43 -112 112 112 gray44 -112 112 112 grey44 -115 115 115 gray45 -115 115 115 grey45 -117 117 117 gray46 -117 117 117 grey46 -120 120 120 gray47 -120 120 120 grey47 -122 122 122 gray48 -122 122 122 grey48 -125 125 125 gray49 -125 125 125 grey49 -127 127 127 gray50 -127 127 127 grey50 -130 130 130 gray51 -130 130 130 grey51 -133 133 133 gray52 -133 133 133 grey52 -135 135 135 gray53 -135 135 135 grey53 -138 138 138 gray54 -138 138 138 grey54 -140 140 140 gray55 -140 140 140 grey55 -143 143 143 gray56 -143 143 143 grey56 -145 145 145 gray57 -145 145 145 grey57 -148 148 148 gray58 -148 148 148 grey58 -150 150 150 gray59 -150 150 150 grey59 -153 153 153 gray60 -153 153 153 grey60 -156 156 156 gray61 -156 156 156 grey61 -158 158 158 gray62 -158 158 158 grey62 -161 161 161 gray63 -161 161 161 grey63 -163 163 163 gray64 -163 163 163 grey64 -166 166 166 gray65 -166 166 166 grey65 -168 168 168 gray66 -168 168 168 grey66 -171 171 171 gray67 -171 171 171 grey67 -173 173 173 gray68 -173 173 173 grey68 -176 176 176 gray69 -176 176 176 grey69 -179 179 179 gray70 -179 179 179 grey70 -181 181 181 gray71 -181 181 181 grey71 -184 184 184 gray72 -184 184 184 grey72 -186 186 186 gray73 -186 186 186 grey73 -189 189 189 gray74 -189 189 189 grey74 -191 191 191 gray75 -191 191 191 grey75 -194 194 194 gray76 -194 194 194 grey76 -196 196 196 gray77 -196 196 196 grey77 -199 199 199 gray78 -199 199 199 grey78 -201 201 201 gray79 -201 201 201 grey79 -204 204 204 gray80 -204 204 204 grey80 -207 207 207 gray81 -207 207 207 grey81 -209 209 209 gray82 -209 209 209 grey82 -212 212 212 gray83 -212 212 212 grey83 -214 214 214 gray84 -214 214 214 grey84 -217 217 217 gray85 -217 217 217 grey85 -219 219 219 gray86 -219 219 219 grey86 -222 222 222 gray87 -222 222 222 grey87 -224 224 224 gray88 -224 224 224 grey88 -227 227 227 gray89 -227 227 227 grey89 -229 229 229 gray90 -229 229 229 grey90 -232 232 232 gray91 -232 232 232 grey91 -235 235 235 gray92 -235 235 235 grey92 -237 237 237 gray93 -237 237 237 grey93 -240 240 240 gray94 -240 240 240 grey94 -242 242 242 gray95 -242 242 242 grey95 -245 245 245 gray96 -245 245 245 grey96 -247 247 247 gray97 -247 247 247 grey97 -250 250 250 gray98 -250 250 250 grey98 -252 252 252 gray99 -252 252 252 grey99 -255 255 255 gray100 -255 255 255 grey100 -169 169 169 dark grey -169 169 169 DarkGrey -169 169 169 dark gray -169 169 169 DarkGray - 0 0 139 dark blue - 0 0 139 DarkBlue - 0 139 139 dark cyan - 0 139 139 DarkCyan -139 0 139 dark magenta -139 0 139 DarkMagenta -139 0 0 dark red -139 0 0 DarkRed -144 238 144 light green -144 238 144 LightGreen -220 20 60 crimson - 75 0 130 indigo -128 128 0 olive -102 51 153 rebecca purple -102 51 153 RebeccaPurple -192 192 192 silver - 0 128 128 teal diff --git a/src/terminal-old/sanitize.zig b/src/terminal-old/sanitize.zig deleted file mode 100644 index f492291aa2..0000000000 --- a/src/terminal-old/sanitize.zig +++ /dev/null @@ -1,13 +0,0 @@ -const std = @import("std"); - -/// Returns true if the data looks safe to paste. -pub fn isSafePaste(data: []const u8) bool { - return std.mem.indexOf(u8, data, "\n") == null; -} - -test isSafePaste { - const testing = std.testing; - try testing.expect(isSafePaste("hello")); - try testing.expect(!isSafePaste("hello\n")); - try testing.expect(!isSafePaste("hello\nworld")); -} diff --git a/src/terminal-old/sgr.zig b/src/terminal-old/sgr.zig deleted file mode 100644 index b23bd15140..0000000000 --- a/src/terminal-old/sgr.zig +++ /dev/null @@ -1,559 +0,0 @@ -//! SGR (Select Graphic Rendition) attrinvbute parsing and types. - -const std = @import("std"); -const testing = std.testing; -const color = @import("color.zig"); - -/// Attribute type for SGR -pub const Attribute = union(enum) { - /// Unset all attributes - unset: void, - - /// Unknown attribute, the raw CSI command parameters are here. - unknown: struct { - /// Full is the full SGR input. - full: []const u16, - - /// Partial is the remaining, where we got hung up. - partial: []const u16, - }, - - /// Bold the text. - bold: void, - reset_bold: void, - - /// Italic text. - italic: void, - reset_italic: void, - - /// Faint/dim text. - /// Note: reset faint is the same SGR code as reset bold - faint: void, - - /// Underline the text - underline: Underline, - reset_underline: void, - underline_color: color.RGB, - @"256_underline_color": u8, - reset_underline_color: void, - - /// Blink the text - blink: void, - reset_blink: void, - - /// Invert fg/bg colors. - inverse: void, - reset_inverse: void, - - /// Invisible - invisible: void, - reset_invisible: void, - - /// Strikethrough the text. - strikethrough: void, - reset_strikethrough: void, - - /// Set foreground color as RGB values. - direct_color_fg: color.RGB, - - /// Set background color as RGB values. - direct_color_bg: color.RGB, - - /// Set the background/foreground as a named color attribute. - @"8_bg": color.Name, - @"8_fg": color.Name, - - /// Reset the fg/bg to their default values. - reset_fg: void, - reset_bg: void, - - /// Set the background/foreground as a named bright color attribute. - @"8_bright_bg": color.Name, - @"8_bright_fg": color.Name, - - /// Set background color as 256-color palette. - @"256_bg": u8, - - /// Set foreground color as 256-color palette. - @"256_fg": u8, - - pub const Underline = enum(u3) { - none = 0, - single = 1, - double = 2, - curly = 3, - dotted = 4, - dashed = 5, - }; -}; - -/// Parser parses the attributes from a list of SGR parameters. -pub const Parser = struct { - params: []const u16, - idx: usize = 0, - - /// True if the separator is a colon - colon: bool = false, - - /// Next returns the next attribute or null if there are no more attributes. - pub fn next(self: *Parser) ?Attribute { - if (self.idx > self.params.len) return null; - - // Implicitly means unset - if (self.params.len == 0) { - self.idx += 1; - return Attribute{ .unset = {} }; - } - - const slice = self.params[self.idx..self.params.len]; - self.idx += 1; - - // Our last one will have an idx be the last value. - if (slice.len == 0) return null; - - switch (slice[0]) { - 0 => return Attribute{ .unset = {} }, - - 1 => return Attribute{ .bold = {} }, - - 2 => return Attribute{ .faint = {} }, - - 3 => return Attribute{ .italic = {} }, - - 4 => blk: { - if (self.colon) { - switch (slice.len) { - // 0 is unreachable because we're here and we read - // an element to get here. - 0 => unreachable, - - // 1 is possible if underline is the last element. - 1 => return Attribute{ .underline = .single }, - - // 2 means we have a specific underline style. - 2 => { - self.idx += 1; - switch (slice[1]) { - 0 => return Attribute{ .reset_underline = {} }, - 1 => return Attribute{ .underline = .single }, - 2 => return Attribute{ .underline = .double }, - 3 => return Attribute{ .underline = .curly }, - 4 => return Attribute{ .underline = .dotted }, - 5 => return Attribute{ .underline = .dashed }, - - // For unknown underline styles, just render - // a single underline. - else => return Attribute{ .underline = .single }, - } - }, - - // Colon-separated must only be 2. - else => break :blk, - } - } - - return Attribute{ .underline = .single }; - }, - - 5 => return Attribute{ .blink = {} }, - - 6 => return Attribute{ .blink = {} }, - - 7 => return Attribute{ .inverse = {} }, - - 8 => return Attribute{ .invisible = {} }, - - 9 => return Attribute{ .strikethrough = {} }, - - 22 => return Attribute{ .reset_bold = {} }, - - 23 => return Attribute{ .reset_italic = {} }, - - 24 => return Attribute{ .reset_underline = {} }, - - 25 => return Attribute{ .reset_blink = {} }, - - 27 => return Attribute{ .reset_inverse = {} }, - - 28 => return Attribute{ .reset_invisible = {} }, - - 29 => return Attribute{ .reset_strikethrough = {} }, - - 30...37 => return Attribute{ - .@"8_fg" = @enumFromInt(slice[0] - 30), - }, - - 38 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; - - // In the 6-len form, ignore the 3rd param. - const rgb = slice[2..5]; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_fg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_fg" = @truncate(slice[2]), - }; - }, - - 39 => return Attribute{ .reset_fg = {} }, - - 40...47 => return Attribute{ - .@"8_bg" = @enumFromInt(slice[0] - 40), - }, - - 48 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; - - // We only support the 5-len form. - const rgb = slice[2..5]; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_bg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_bg" = @truncate(slice[2]), - }; - }, - - 49 => return Attribute{ .reset_bg = {} }, - - 58 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; - - // In the 6-len form, ignore the 3rd param. Otherwise, use it. - const rgb = if (slice.len == 5) slice[2..5] else rgb: { - // Consume one more element - self.idx += 1; - break :rgb slice[3..6]; - }; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .underline_color = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_underline_color" = @truncate(slice[2]), - }; - }, - - 59 => return Attribute{ .reset_underline_color = {} }, - - 90...97 => return Attribute{ - // 82 instead of 90 to offset to "bright" colors - .@"8_bright_fg" = @enumFromInt(slice[0] - 82), - }, - - 100...107 => return Attribute{ - .@"8_bright_bg" = @enumFromInt(slice[0] - 92), - }, - - else => {}, - } - - return Attribute{ .unknown = .{ .full = self.params, .partial = slice } }; - } -}; - -fn testParse(params: []const u16) Attribute { - var p: Parser = .{ .params = params }; - return p.next().?; -} - -fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .colon = true }; - return p.next().?; -} - -test "sgr: Parser" { - try testing.expect(testParse(&[_]u16{}) == .unset); - try testing.expect(testParse(&[_]u16{0}) == .unset); - - { - const v = testParse(&[_]u16{ 38, 2, 40, 44, 52 }); - try testing.expect(v == .direct_color_fg); - try testing.expectEqual(@as(u8, 40), v.direct_color_fg.r); - try testing.expectEqual(@as(u8, 44), v.direct_color_fg.g); - try testing.expectEqual(@as(u8, 52), v.direct_color_fg.b); - } - - try testing.expect(testParse(&[_]u16{ 38, 2, 44, 52 }) == .unknown); - - { - const v = testParse(&[_]u16{ 48, 2, 40, 44, 52 }); - try testing.expect(v == .direct_color_bg); - try testing.expectEqual(@as(u8, 40), v.direct_color_bg.r); - try testing.expectEqual(@as(u8, 44), v.direct_color_bg.g); - try testing.expectEqual(@as(u8, 52), v.direct_color_bg.b); - } - - try testing.expect(testParse(&[_]u16{ 48, 2, 44, 52 }) == .unknown); -} - -test "sgr: Parser multiple" { - var p: Parser = .{ .params = &[_]u16{ 0, 38, 2, 40, 44, 52 } }; - try testing.expect(p.next().? == .unset); - try testing.expect(p.next().? == .direct_color_fg); - try testing.expect(p.next() == null); - try testing.expect(p.next() == null); -} - -test "sgr: bold" { - { - const v = testParse(&[_]u16{1}); - try testing.expect(v == .bold); - } - - { - const v = testParse(&[_]u16{22}); - try testing.expect(v == .reset_bold); - } -} - -test "sgr: italic" { - { - const v = testParse(&[_]u16{3}); - try testing.expect(v == .italic); - } - - { - const v = testParse(&[_]u16{23}); - try testing.expect(v == .reset_italic); - } -} - -test "sgr: underline" { - { - const v = testParse(&[_]u16{4}); - try testing.expect(v == .underline); - } - - { - const v = testParse(&[_]u16{24}); - try testing.expect(v == .reset_underline); - } -} - -test "sgr: underline styles" { - { - const v = testParseColon(&[_]u16{ 4, 2 }); - try testing.expect(v == .underline); - try testing.expect(v.underline == .double); - } - - { - const v = testParseColon(&[_]u16{ 4, 0 }); - try testing.expect(v == .reset_underline); - } - - { - const v = testParseColon(&[_]u16{ 4, 1 }); - try testing.expect(v == .underline); - try testing.expect(v.underline == .single); - } - - { - const v = testParseColon(&[_]u16{ 4, 3 }); - try testing.expect(v == .underline); - try testing.expect(v.underline == .curly); - } - - { - const v = testParseColon(&[_]u16{ 4, 4 }); - try testing.expect(v == .underline); - try testing.expect(v.underline == .dotted); - } - - { - const v = testParseColon(&[_]u16{ 4, 5 }); - try testing.expect(v == .underline); - try testing.expect(v.underline == .dashed); - } -} - -test "sgr: blink" { - { - const v = testParse(&[_]u16{5}); - try testing.expect(v == .blink); - } - - { - const v = testParse(&[_]u16{6}); - try testing.expect(v == .blink); - } - - { - const v = testParse(&[_]u16{25}); - try testing.expect(v == .reset_blink); - } -} - -test "sgr: inverse" { - { - const v = testParse(&[_]u16{7}); - try testing.expect(v == .inverse); - } - - { - const v = testParse(&[_]u16{27}); - try testing.expect(v == .reset_inverse); - } -} - -test "sgr: strikethrough" { - { - const v = testParse(&[_]u16{9}); - try testing.expect(v == .strikethrough); - } - - { - const v = testParse(&[_]u16{29}); - try testing.expect(v == .reset_strikethrough); - } -} - -test "sgr: 8 color" { - var p: Parser = .{ .params = &[_]u16{ 31, 43, 90, 103 } }; - - { - const v = p.next().?; - try testing.expect(v == .@"8_fg"); - try testing.expect(v.@"8_fg" == .red); - } - - { - const v = p.next().?; - try testing.expect(v == .@"8_bg"); - try testing.expect(v.@"8_bg" == .yellow); - } - - { - const v = p.next().?; - try testing.expect(v == .@"8_bright_fg"); - try testing.expect(v.@"8_bright_fg" == .bright_black); - } - - { - const v = p.next().?; - try testing.expect(v == .@"8_bright_bg"); - try testing.expect(v.@"8_bright_bg" == .bright_yellow); - } -} - -test "sgr: 256 color" { - var p: Parser = .{ .params = &[_]u16{ 38, 5, 161, 48, 5, 236 } }; - try testing.expect(p.next().? == .@"256_fg"); - try testing.expect(p.next().? == .@"256_bg"); - try testing.expect(p.next() == null); -} - -test "sgr: 256 color underline" { - var p: Parser = .{ .params = &[_]u16{ 58, 5, 9 } }; - try testing.expect(p.next().? == .@"256_underline_color"); - try testing.expect(p.next() == null); -} - -test "sgr: 24-bit bg color" { - { - const v = testParseColon(&[_]u16{ 48, 2, 1, 2, 3 }); - try testing.expect(v == .direct_color_bg); - try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r); - try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g); - try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b); - } -} - -test "sgr: underline color" { - { - const v = testParseColon(&[_]u16{ 58, 2, 1, 2, 3 }); - try testing.expect(v == .underline_color); - try testing.expectEqual(@as(u8, 1), v.underline_color.r); - try testing.expectEqual(@as(u8, 2), v.underline_color.g); - try testing.expectEqual(@as(u8, 3), v.underline_color.b); - } - - { - const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 }); - try testing.expect(v == .underline_color); - try testing.expectEqual(@as(u8, 1), v.underline_color.r); - try testing.expectEqual(@as(u8, 2), v.underline_color.g); - try testing.expectEqual(@as(u8, 3), v.underline_color.b); - } -} - -test "sgr: reset underline color" { - var p: Parser = .{ .params = &[_]u16{59} }; - try testing.expect(p.next().? == .reset_underline_color); -} - -test "sgr: invisible" { - var p: Parser = .{ .params = &[_]u16{ 8, 28 } }; - try testing.expect(p.next().? == .invisible); - try testing.expect(p.next().? == .reset_invisible); -} - -test "sgr: underline, bg, and fg" { - var p: Parser = .{ - .params = &[_]u16{ 4, 38, 2, 255, 247, 219, 48, 2, 242, 93, 147, 4 }, - }; - { - const v = p.next().?; - try testing.expect(v == .underline); - try testing.expectEqual(Attribute.Underline.single, v.underline); - } - { - const v = p.next().?; - try testing.expect(v == .direct_color_fg); - try testing.expectEqual(@as(u8, 255), v.direct_color_fg.r); - try testing.expectEqual(@as(u8, 247), v.direct_color_fg.g); - try testing.expectEqual(@as(u8, 219), v.direct_color_fg.b); - } - { - const v = p.next().?; - try testing.expect(v == .direct_color_bg); - try testing.expectEqual(@as(u8, 242), v.direct_color_bg.r); - try testing.expectEqual(@as(u8, 93), v.direct_color_bg.g); - try testing.expectEqual(@as(u8, 147), v.direct_color_bg.b); - } - { - const v = p.next().?; - try testing.expect(v == .underline); - try testing.expectEqual(Attribute.Underline.single, v.underline); - } -} - -test "sgr: direct color fg missing color" { - // This used to crash - var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false }; - while (p.next()) |_| {} -} - -test "sgr: direct color bg missing color" { - // This used to crash - var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; - while (p.next()) |_| {} -} diff --git a/src/terminal-old/simdvt.zig b/src/terminal-old/simdvt.zig deleted file mode 100644 index be5e4fcb70..0000000000 --- a/src/terminal-old/simdvt.zig +++ /dev/null @@ -1,5 +0,0 @@ -pub usingnamespace @import("simdvt/parser.zig"); - -test { - @import("std").testing.refAllDecls(@This()); -} diff --git a/src/terminal-old/stream.zig b/src/terminal-old/stream.zig deleted file mode 100644 index fc97d36850..0000000000 --- a/src/terminal-old/stream.zig +++ /dev/null @@ -1,2014 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; -const simd = @import("../simd/main.zig"); -const Parser = @import("Parser.zig"); -const ansi = @import("ansi.zig"); -const charsets = @import("charsets.zig"); -const device_status = @import("device_status.zig"); -const csi = @import("csi.zig"); -const kitty = @import("kitty.zig"); -const modes = @import("modes.zig"); -const osc = @import("osc.zig"); -const sgr = @import("sgr.zig"); -const UTF8Decoder = @import("UTF8Decoder.zig"); -const MouseShape = @import("mouse_shape.zig").MouseShape; - -const log = std.log.scoped(.stream); - -/// Returns a type that can process a stream of tty control characters. -/// This will call various callback functions on type T. Type T only has to -/// implement the callbacks it cares about; any unimplemented callbacks will -/// logged at runtime. -/// -/// To figure out what callbacks exist, search the source for "hasDecl". This -/// isn't ideal but for now that's the best approach. -/// -/// This is implemented this way because we purposely do NOT want dynamic -/// dispatch for performance reasons. The way this is implemented forces -/// comptime resolution for all function calls. -pub fn Stream(comptime Handler: type) type { - return struct { - const Self = @This(); - - // We use T with @hasDecl so it needs to be a struct. Unwrap the - // pointer if we were given one. - const T = switch (@typeInfo(Handler)) { - .Pointer => |p| p.child, - else => Handler, - }; - - handler: Handler, - parser: Parser = .{}, - utf8decoder: UTF8Decoder = .{}, - - pub fn deinit(self: *Self) void { - self.parser.deinit(); - } - - /// Process a string of characters. - pub fn nextSlice(self: *Self, input: []const u8) !void { - // This is the maximum number of codepoints we can decode - // at one time for this function call. This is somewhat arbitrary - // so if someone can demonstrate a better number then we can switch. - var cp_buf: [4096]u32 = undefined; - - // Split the input into chunks that fit into cp_buf. - var i: usize = 0; - while (true) { - const len = @min(cp_buf.len, input.len - i); - try self.nextSliceCapped(input[i .. i + len], &cp_buf); - i += len; - if (i >= input.len) break; - } - } - - fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void { - assert(input.len <= cp_buf.len); - - var offset: usize = 0; - - // If the scalar UTF-8 decoder was in the middle of processing - // a code sequence, we continue until it's not. - while (self.utf8decoder.state != 0) { - if (offset >= input.len) return; - try self.nextUtf8(input[offset]); - offset += 1; - } - if (offset >= input.len) return; - - // If we're not in the ground state then we process until - // we are. This can happen if the last chunk of input put us - // in the middle of a control sequence. - offset += try self.consumeUntilGround(input[offset..]); - if (offset >= input.len) return; - offset += try self.consumeAllEscapes(input[offset..]); - - // If we're in the ground state then we can use SIMD to process - // input until we see an ESC (0x1B), since all other characters - // up to that point are just UTF-8. - while (self.parser.state == .ground and offset < input.len) { - const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); - for (cp_buf[0..res.decoded]) |cp| { - if (cp <= 0xF) { - try self.execute(@intCast(cp)); - } else { - try self.print(@intCast(cp)); - } - } - // Consume the bytes we just processed. - offset += res.consumed; - - if (offset >= input.len) return; - - // If our offset is NOT an escape then we must have a - // partial UTF-8 sequence. In that case, we pass it off - // to the scalar parser. - if (input[offset] != 0x1B) { - const rem = input[offset..]; - for (rem) |c| try self.nextUtf8(c); - return; - } - - // Process control sequences until we run out. - offset += try self.consumeAllEscapes(input[offset..]); - } - } - - /// Parses back-to-back escape sequences until none are left. - /// Returns the number of bytes consumed from the provided input. - /// - /// Expects input to start with 0x1B, use consumeUntilGround first - /// if the stream may be in the middle of an escape sequence. - fn consumeAllEscapes(self: *Self, input: []const u8) !usize { - var offset: usize = 0; - while (input[offset] == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - offset += 1; - offset += try self.consumeUntilGround(input[offset..]); - if (offset >= input.len) return input.len; - } - return offset; - } - - /// Parses escape sequences until the parser reaches the ground state. - /// Returns the number of bytes consumed from the provided input. - fn consumeUntilGround(self: *Self, input: []const u8) !usize { - var offset: usize = 0; - while (self.parser.state != .ground) { - if (offset >= input.len) return input.len; - try self.nextNonUtf8(input[offset]); - offset += 1; - } - return offset; - } - - /// Like nextSlice but takes one byte and is necessarilly a scalar - /// operation that can't use SIMD. Prefer nextSlice if you can and - /// try to get multiple bytes at once. - pub fn next(self: *Self, c: u8) !void { - // The scalar path can be responsible for decoding UTF-8. - if (self.parser.state == .ground and c != 0x1B) { - try self.nextUtf8(c); - return; - } - - try self.nextNonUtf8(c); - } - - /// Process the next byte and print as necessary. - /// - /// This assumes we're in the UTF-8 decoding state. If we may not - /// be in the UTF-8 decoding state call nextSlice or next. - fn nextUtf8(self: *Self, c: u8) !void { - assert(self.parser.state == .ground and c != 0x1B); - - const res = self.utf8decoder.next(c); - const consumed = res[1]; - if (res[0]) |codepoint| { - if (codepoint <= 0xF) { - try self.execute(@intCast(codepoint)); - } else { - try self.print(@intCast(codepoint)); - } - } - if (!consumed) { - const retry = self.utf8decoder.next(c); - // It should be impossible for the decoder - // to not consume the byte twice in a row. - assert(retry[1] == true); - if (retry[0]) |codepoint| { - if (codepoint <= 0xF) { - try self.execute(@intCast(codepoint)); - } else { - try self.print(@intCast(codepoint)); - } - } - } - } - - /// Process the next character and call any callbacks if necessary. - /// - /// This assumes that we're not in the UTF-8 decoding state. If - /// we may be in the UTF-8 decoding state call nextSlice or next. - fn nextNonUtf8(self: *Self, c: u8) !void { - assert(self.parser.state != .ground or c == 0x1B); - - // Fast path for ESC - if (self.parser.state == .ground and c == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - return; - } - // Fast path for CSI entry. - if (self.parser.state == .escape and c == '[') { - self.parser.state = .csi_entry; - return; - } - // Fast path for CSI params. - if (self.parser.state == .csi_param) csi_param: { - switch (c) { - // A C0 escape (yes, this is valid): - 0x00...0x0F => try self.execute(c), - // We ignore C0 escapes > 0xF since execute - // doesn't have processing for them anyway: - 0x10...0x17, 0x19, 0x1C...0x1F => {}, - // We don't currently have any handling for - // 0x18 or 0x1A, but they should still move - // the parser state to ground. - 0x18, 0x1A => self.parser.state = .ground, - // A parameter digit: - '0'...'9' => if (self.parser.params_idx < 16) { - self.parser.param_acc *|= 10; - self.parser.param_acc +|= c - '0'; - // The parser's CSI param action uses param_acc_idx - // to decide if there's a final param that needs to - // be consumed or not, but it doesn't matter really - // what it is as long as it's not 0. - self.parser.param_acc_idx |= 1; - }, - // A parameter separator: - ':', ';' => if (self.parser.params_idx < 16) { - self.parser.params[self.parser.params_idx] = self.parser.param_acc; - self.parser.params_idx += 1; - - self.parser.param_acc = 0; - self.parser.param_acc_idx = 0; - - // Keep track of separator state. - const sep: Parser.ParamSepState = @enumFromInt(c); - if (self.parser.params_idx == 1) self.parser.params_sep = sep; - if (self.parser.params_sep != sep) self.parser.params_sep = .mixed; - }, - // Explicitly ignored: - 0x7F => {}, - // Defer to the state machine to - // handle any other characters: - else => break :csi_param, - } - return; - } - - const actions = self.parser.next(c); - for (actions) |action_opt| { - const action = action_opt orelse continue; - - // if (action != .print) { - // log.info("action: {}", .{action}); - // } - - // If this handler handles everything manually then we do nothing - // if it can be processed. - if (@hasDecl(T, "handleManually")) { - const processed = self.handler.handleManually(action) catch |err| err: { - log.warn("error handling action manually err={} action={}", .{ - err, - action, - }); - - break :err false; - }; - - if (processed) continue; - } - - switch (action) { - .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), - .execute => |code| try self.execute(code), - .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), - .esc_dispatch => |esc| try self.escDispatch(esc), - .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { - try self.handler.dcsHook(dcs); - } else log.warn("unimplemented DCS hook", .{}), - .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { - try self.handler.dcsPut(code); - } else log.warn("unimplemented DCS put: {x}", .{code}), - .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { - try self.handler.dcsUnhook(); - } else log.warn("unimplemented DCS unhook", .{}), - .apc_start => if (@hasDecl(T, "apcStart")) { - try self.handler.apcStart(); - } else log.warn("unimplemented APC start", .{}), - .apc_put => |code| if (@hasDecl(T, "apcPut")) { - try self.handler.apcPut(code); - } else log.warn("unimplemented APC put: {x}", .{code}), - .apc_end => if (@hasDecl(T, "apcEnd")) { - try self.handler.apcEnd(); - } else log.warn("unimplemented APC end", .{}), - } - } - } - - pub fn print(self: *Self, c: u21) !void { - if (@hasDecl(T, "print")) { - try self.handler.print(c); - } - } - - pub fn execute(self: *Self, c: u8) !void { - switch (@as(ansi.C0, @enumFromInt(c))) { - // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 - .NUL, .SOH, .STX => {}, - - .ENQ => if (@hasDecl(T, "enquiry")) - try self.handler.enquiry() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BEL => if (@hasDecl(T, "bell")) - try self.handler.bell() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BS => if (@hasDecl(T, "backspace")) - try self.handler.backspace() - else - log.warn("unimplemented execute: {x}", .{c}), - - .HT => if (@hasDecl(T, "horizontalTab")) - try self.handler.horizontalTab(1) - else - log.warn("unimplemented execute: {x}", .{c}), - - .LF, .VT, .FF => if (@hasDecl(T, "linefeed")) - try self.handler.linefeed() - else - log.warn("unimplemented execute: {x}", .{c}), - - .CR => if (@hasDecl(T, "carriageReturn")) - try self.handler.carriageReturn() - else - log.warn("unimplemented execute: {x}", .{c}), - - .SO => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G1, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), - - .SI => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G0, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), - - else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), - } - } - - fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { - // Handles aliases first - const action = switch (input.final) { - // Alias for set cursor position - 'f' => blk: { - var copy = input; - copy.final = 'H'; - break :blk copy; - }, - - else => input, - }; - - switch (action.final) { - // CUU - Cursor Up - 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor up command: {}", .{action}); - return; - }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CUD - Cursor Down - 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor down command: {}", .{action}); - return; - }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CUF - Cursor Right - 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor right command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CUB - Cursor Left - 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor left command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CNL - Cursor Next Line - 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor up command: {}", .{action}); - return; - }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CPL - Cursor Previous Line - 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid cursor down command: {}", .{action}); - return; - }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // HPA - Cursor Horizontal Position Absolute - // TODO: test - 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (action.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(action.params[0]), - else => log.warn("invalid HPA command: {}", .{action}), - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // CUP - Set Cursor Position. - // TODO: test - 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (action.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(action.params[0], 1), - 2 => try self.handler.setCursorPos(action.params[0], action.params[1]), - else => log.warn("invalid CUP command: {}", .{action}), - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // CHT - Cursor Horizontal Tabulation - 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid horizontal tab command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // Erase Display - 'J' => if (@hasDecl(T, "eraseDisplay")) { - const protected_: ?bool = switch (action.intermediates.len) { - 0 => false, - 1 => if (action.intermediates[0] == '?') true else null, - else => null, - }; - - const protected = protected_ orelse { - log.warn("invalid erase display command: {}", .{action}); - return; - }; - - const mode_: ?csi.EraseDisplay = switch (action.params.len) { - 0 => .below, - 1 => if (action.params[0] <= 3) - std.meta.intToEnum(csi.EraseDisplay, action.params[0]) catch null - else - null, - else => null, - }; - - const mode = mode_ orelse { - log.warn("invalid erase display command: {}", .{action}); - return; - }; - - try self.handler.eraseDisplay(mode, protected); - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // Erase Line - 'K' => if (@hasDecl(T, "eraseLine")) { - const protected_: ?bool = switch (action.intermediates.len) { - 0 => false, - 1 => if (action.intermediates[0] == '?') true else null, - else => null, - }; - - const protected = protected_ orelse { - log.warn("invalid erase line command: {}", .{action}); - return; - }; - - const mode_: ?csi.EraseLine = switch (action.params.len) { - 0 => .right, - 1 => if (action.params[0] < 3) @enumFromInt(action.params[0]) else null, - else => null, - }; - - const mode = mode_ orelse { - log.warn("invalid erase line command: {}", .{action}); - return; - }; - - try self.handler.eraseLine(mode, protected); - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // IL - Insert Lines - // TODO: test - 'L' => if (@hasDecl(T, "insertLines")) switch (action.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(action.params[0]), - else => log.warn("invalid IL command: {}", .{action}), - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // DL - Delete Lines - // TODO: test - 'M' => if (@hasDecl(T, "deleteLines")) switch (action.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(action.params[0]), - else => log.warn("invalid DL command: {}", .{action}), - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // Delete Character (DCH) - 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid delete characters command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // Scroll Up (SD) - - 'S' => switch (action.intermediates.len) { - 0 => if (@hasDecl(T, "scrollUp")) try self.handler.scrollUp( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid scroll up command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - else => log.warn( - "ignoring unimplemented CSI S with intermediates: {s}", - .{action.intermediates}, - ), - }, - - // Scroll Down (SD) - 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid scroll down command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // Cursor Tabulation Control - 'W' => { - switch (action.params.len) { - 0 => if (action.intermediates.len == 1 and action.intermediates[0] == '?') { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {}", .{action}); - }, - - 1 => switch (action.params[0]) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{action}), - - 2 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.current) - else - log.warn("unimplemented tab clear callback: {}", .{action}), - - 5 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.all) - else - log.warn("unimplemented tab clear callback: {}", .{action}), - - else => {}, - }, - - else => {}, - } - - log.warn("invalid cursor tabulation control: {}", .{action}); - return; - }, - - // Erase Characters (ECH) - 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid erase characters command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // CHT - Cursor Horizontal Tabulation Back - 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid horizontal tab back command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // HPR - Cursor Horizontal Position Relative - 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid HPR command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // Repeat Previous Char (REP) - 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid print repeat command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // c - Device Attributes (DA1) - 'c' => if (@hasDecl(T, "deviceAttributes")) { - const req: ansi.DeviceAttributeReq = switch (action.intermediates.len) { - 0 => ansi.DeviceAttributeReq.primary, - 1 => switch (action.intermediates[0]) { - '>' => ansi.DeviceAttributeReq.secondary, - '=' => ansi.DeviceAttributeReq.tertiary, - else => null, - }, - else => @as(?ansi.DeviceAttributeReq, null), - } orelse { - log.warn("invalid device attributes command: {}", .{action}); - return; - }; - - try self.handler.deviceAttributes(req, action.params); - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // VPA - Cursor Vertical Position Absolute - 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid VPA command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // VPR - Cursor Vertical Position Relative - 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (action.params.len) { - 0 => 1, - 1 => action.params[0], - else => { - log.warn("invalid VPR command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // TBC - Tab Clear - // TODO: test - 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (action.params.len) { - 1 => @enumFromInt(action.params[0]), - else => { - log.warn("invalid tab clear command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}), - - // SM - Set Mode - 'h' => if (@hasDecl(T, "setMode")) mode: { - const ansi_mode = ansi: { - if (action.intermediates.len == 0) break :ansi true; - if (action.intermediates.len == 1 and - action.intermediates[0] == '?') break :ansi false; - - log.warn("invalid set mode command: {}", .{action}); - break :mode; - }; - - for (action.params) |mode_int| { - if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, true); - } else { - log.warn("unimplemented mode: {}", .{mode_int}); - } - } - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // RM - Reset Mode - 'l' => if (@hasDecl(T, "setMode")) mode: { - const ansi_mode = ansi: { - if (action.intermediates.len == 0) break :ansi true; - if (action.intermediates.len == 1 and - action.intermediates[0] == '?') break :ansi false; - - log.warn("invalid set mode command: {}", .{action}); - break :mode; - }; - - for (action.params) |mode_int| { - if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, false); - } else { - log.warn("unimplemented mode: {}", .{mode_int}); - } - } - } else log.warn("unimplemented CSI callback: {}", .{action}), - - // SGR - Select Graphic Rendition - 'm' => switch (action.intermediates.len) { - 0 => if (@hasDecl(T, "setAttribute")) { - // log.info("parse SGR params={any}", .{action.params}); - var p: sgr.Parser = .{ .params = action.params, .colon = action.sep == .colon }; - while (p.next()) |attr| { - // log.info("SGR attribute: {}", .{attr}); - try self.handler.setAttribute(attr); - } - } else log.warn("unimplemented CSI callback: {}", .{action}), - - 1 => switch (action.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) blk: { - if (action.params.len == 0) { - // Reset - try self.handler.setModifyKeyFormat(.{ .legacy = {} }); - break :blk; - } - - var format: ansi.ModifyKeyFormat = switch (action.params[0]) { - 0 => .{ .legacy = {} }, - 1 => .{ .cursor_keys = {} }, - 2 => .{ .function_keys = {} }, - 4 => .{ .other_keys = .none }, - else => { - log.warn("invalid setModifyKeyFormat: {}", .{action}); - break :blk; - }, - }; - - if (action.params.len > 2) { - log.warn("invalid setModifyKeyFormat: {}", .{action}); - break :blk; - } - - if (action.params.len == 2) { - switch (format) { - // We don't support any of the subparams yet for these. - .legacy => {}, - .cursor_keys => {}, - .function_keys => {}, - - // We only support the numeric form. - .other_keys => |*v| switch (action.params[1]) { - 2 => v.* = .numeric, - else => v.* = .none, - }, - } - } - - try self.handler.setModifyKeyFormat(format); - } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), - - else => log.warn( - "unknown CSI m with intermediate: {}", - .{action.intermediates[0]}, - ), - }, - - else => { - // Nothing, but I wanted a place to put this comment: - // there are others forms of CSI m that have intermediates. - // `vim --clean` uses `CSI ? 4 m` and I don't know what - // that means. And there is also `CSI > m` which is used - // to control modifier key reporting formats that we don't - // support yet. - log.warn( - "ignoring unimplemented CSI m with intermediates: {s}", - .{action.intermediates}, - ); - }, - }, - - // TODO: test - 'n' => { - // Handle deviceStatusReport first - if (action.intermediates.len == 0 or - action.intermediates[0] == '?') - { - if (!@hasDecl(T, "deviceStatusReport")) { - log.warn("unimplemented CSI callback: {}", .{action}); - return; - } - - if (action.params.len != 1) { - log.warn("invalid device status report command: {}", .{action}); - return; - } - - const question = question: { - if (action.intermediates.len == 0) break :question false; - if (action.intermediates.len == 1 and - action.intermediates[0] == '?') break :question true; - - log.warn("invalid set mode command: {}", .{action}); - return; - }; - - const req = device_status.reqFromInt(action.params[0], question) orelse { - log.warn("invalid device status report command: {}", .{action}); - return; - }; - - try self.handler.deviceStatusReport(req); - return; - } - - // Handle other forms of CSI n - switch (action.intermediates.len) { - 0 => unreachable, // handled above - - 1 => switch (action.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) { - // This isn't strictly correct. CSI > n has parameters that - // control what exactly is being disabled. However, we - // only support reverting back to modify other keys in - // numeric except format. - try self.handler.setModifyKeyFormat(.{ .other_keys = .numeric_except }); - } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), - - else => log.warn( - "unknown CSI n with intermediate: {}", - .{action.intermediates[0]}, - ), - }, - - else => log.warn( - "ignoring unimplemented CSI n with intermediates: {s}", - .{action.intermediates}, - ), - } - }, - - // DECRQM - Request Mode - 'p' => switch (action.intermediates.len) { - 2 => decrqm: { - const ansi_mode = ansi: { - switch (action.intermediates.len) { - 1 => if (action.intermediates[0] == '$') break :ansi true, - 2 => if (action.intermediates[0] == '?' and - action.intermediates[1] == '$') break :ansi false, - else => {}, - } - - log.warn( - "ignoring unimplemented CSI p with intermediates: {s}", - .{action.intermediates}, - ); - break :decrqm; - }; - - if (action.params.len != 1) { - log.warn("invalid DECRQM command: {}", .{action}); - break :decrqm; - } - - if (@hasDecl(T, "requestMode")) { - try self.handler.requestMode(action.params[0], ansi_mode); - } else log.warn("unimplemented DECRQM callback: {}", .{action}); - }, - - else => log.warn( - "ignoring unimplemented CSI p with intermediates: {s}", - .{action.intermediates}, - ), - }, - - 'q' => switch (action.intermediates.len) { - 1 => switch (action.intermediates[0]) { - // DECSCUSR - Select Cursor Style - // TODO: test - ' ' => { - if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( - switch (action.params.len) { - 0 => ansi.CursorStyle.default, - 1 => @enumFromInt(action.params[0]), - else => { - log.warn("invalid set curor style command: {}", .{action}); - return; - }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{action}); - }, - - // DECSCA - '"' => { - if (@hasDecl(T, "setProtectedMode")) { - const mode_: ?ansi.ProtectedMode = switch (action.params.len) { - else => null, - 0 => .off, - 1 => switch (action.params[0]) { - 0, 2 => .off, - 1 => .dec, - else => null, - }, - }; - - const mode = mode_ orelse { - log.warn("invalid set protected mode command: {}", .{action}); - return; - }; - - try self.handler.setProtectedMode(mode); - } else log.warn("unimplemented CSI callback: {}", .{action}); - }, - - // XTVERSION - '>' => { - if (@hasDecl(T, "reportXtversion")) try self.handler.reportXtversion(); - }, - else => { - log.warn( - "ignoring unimplemented CSI q with intermediates: {s}", - .{action.intermediates}, - ); - }, - }, - - else => log.warn( - "ignoring unimplemented CSI p with intermediates: {s}", - .{action.intermediates}, - ), - }, - - 'r' => switch (action.intermediates.len) { - // DECSTBM - Set Top and Bottom Margins - 0 => if (@hasDecl(T, "setTopAndBottomMargin")) { - switch (action.params.len) { - 0 => try self.handler.setTopAndBottomMargin(0, 0), - 1 => try self.handler.setTopAndBottomMargin(action.params[0], 0), - 2 => try self.handler.setTopAndBottomMargin(action.params[0], action.params[1]), - else => log.warn("invalid DECSTBM command: {}", .{action}), - } - } else log.warn( - "unimplemented CSI callback: {}", - .{action}, - ), - - 1 => switch (action.intermediates[0]) { - // Restore Mode - '?' => if (@hasDecl(T, "restoreMode")) { - for (action.params) |mode_int| { - if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.restoreMode(mode); - } else { - log.warn( - "unimplemented restore mode: {}", - .{mode_int}, - ); - } - } - }, - - else => log.warn( - "unknown CSI s with intermediate: {}", - .{action}, - ), - }, - - else => log.warn( - "ignoring unimplemented CSI s with intermediates: {s}", - .{action}, - ), - }, - - 's' => switch (action.intermediates.len) { - // DECSLRM - 0 => if (@hasDecl(T, "setLeftAndRightMargin")) { - switch (action.params.len) { - // CSI S is ambiguous with zero params so we defer - // to our handler to do the proper logic. If mode 69 - // is set, then we should invoke DECSLRM, otherwise - // we should invoke SC. - 0 => try self.handler.setLeftAndRightMarginAmbiguous(), - 1 => try self.handler.setLeftAndRightMargin(action.params[0], 0), - 2 => try self.handler.setLeftAndRightMargin(action.params[0], action.params[1]), - else => log.warn("invalid DECSLRM command: {}", .{action}), - } - } else log.warn( - "unimplemented CSI callback: {}", - .{action}, - ), - - 1 => switch (action.intermediates[0]) { - '?' => if (@hasDecl(T, "saveMode")) { - for (action.params) |mode_int| { - if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.saveMode(mode); - } else { - log.warn( - "unimplemented save mode: {}", - .{mode_int}, - ); - } - } - }, - - // XTSHIFTESCAPE - '>' => if (@hasDecl(T, "setMouseShiftCapture")) capture: { - const capture = switch (action.params.len) { - 0 => false, - 1 => switch (action.params[0]) { - 0 => false, - 1 => true, - else => { - log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); - break :capture; - }, - }, - else => { - log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); - break :capture; - }, - }; - - try self.handler.setMouseShiftCapture(capture); - } else log.warn( - "unimplemented CSI callback: {}", - .{action}, - ), - - else => log.warn( - "unknown CSI s with intermediate: {}", - .{action}, - ), - }, - - else => log.warn( - "ignoring unimplemented CSI s with intermediates: {s}", - .{action}, - ), - }, - - 'u' => switch (action.intermediates.len) { - 0 => if (@hasDecl(T, "restoreCursor")) - try self.handler.restoreCursor() - else - log.warn("unimplemented CSI callback: {}", .{action}), - - // Kitty keyboard protocol - 1 => switch (action.intermediates[0]) { - '?' => if (@hasDecl(T, "queryKittyKeyboard")) { - try self.handler.queryKittyKeyboard(); - }, - - '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { - const flags: u5 = if (action.params.len == 1) - std.math.cast(u5, action.params[0]) orelse { - log.warn("invalid pushKittyKeyboard command: {}", .{action}); - break :push; - } - else - 0; - - try self.handler.pushKittyKeyboard(@bitCast(flags)); - }, - - '<' => if (@hasDecl(T, "popKittyKeyboard")) { - const number: u16 = if (action.params.len == 1) - action.params[0] - else - 1; - - try self.handler.popKittyKeyboard(number); - }, - - '=' => if (@hasDecl(T, "setKittyKeyboard")) set: { - const flags: u5 = if (action.params.len >= 1) - std.math.cast(u5, action.params[0]) orelse { - log.warn("invalid setKittyKeyboard command: {}", .{action}); - break :set; - } - else - 0; - - const number: u16 = if (action.params.len >= 2) - action.params[1] - else - 1; - - const mode: kitty.KeySetMode = switch (number) { - 0 => .set, - 1 => .@"or", - 2 => .not, - else => { - log.warn("invalid setKittyKeyboard command: {}", .{action}); - break :set; - }, - }; - - try self.handler.setKittyKeyboard( - mode, - @bitCast(flags), - ); - }, - - else => log.warn( - "unknown CSI s with intermediate: {}", - .{action}, - ), - }, - - else => log.warn( - "ignoring unimplemented CSI u: {}", - .{action}, - ), - }, - - // ICH - Insert Blanks - '@' => switch (action.intermediates.len) { - 0 => if (@hasDecl(T, "insertBlanks")) switch (action.params.len) { - 0 => try self.handler.insertBlanks(1), - 1 => try self.handler.insertBlanks(action.params[0]), - else => log.warn("invalid ICH command: {}", .{action}), - } else log.warn("unimplemented CSI callback: {}", .{action}), - - else => log.warn( - "ignoring unimplemented CSI @: {}", - .{action}, - ), - }, - - // DECSASD - Select Active Status Display - '}' => { - const success = decsasd: { - // Verify we're getting a DECSASD command - if (action.intermediates.len != 1 or action.intermediates[0] != '$') - break :decsasd false; - if (action.params.len != 1) - break :decsasd false; - if (!@hasDecl(T, "setActiveStatusDisplay")) - break :decsasd false; - - try self.handler.setActiveStatusDisplay(@enumFromInt(action.params[0])); - break :decsasd true; - }; - - if (!success) log.warn("unimplemented CSI callback: {}", .{action}); - }, - - else => if (@hasDecl(T, "csiUnimplemented")) - try self.handler.csiUnimplemented(action) - else - log.warn("unimplemented CSI action: {}", .{action}), - } - } - - fn oscDispatch(self: *Self, cmd: osc.Command) !void { - switch (cmd) { - .change_window_title => |title| { - if (@hasDecl(T, "changeWindowTitle")) { - if (!std.unicode.utf8ValidateSlice(title)) { - log.warn("change title request: invalid utf-8, ignoring request", .{}); - return; - } - - try self.handler.changeWindowTitle(title); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .change_window_icon => |icon| { - log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); - }, - - .clipboard_contents => |clip| { - if (@hasDecl(T, "clipboardContents")) { - try self.handler.clipboardContents(clip.kind, clip.data); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .prompt_start => |v| { - if (@hasDecl(T, "promptStart")) { - switch (v.kind) { - .primary, .right => try self.handler.promptStart(v.aid, v.redraw), - .continuation => try self.handler.promptContinuation(v.aid), - } - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .prompt_end => { - if (@hasDecl(T, "promptEnd")) { - try self.handler.promptEnd(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .end_of_input => { - if (@hasDecl(T, "endOfInput")) { - try self.handler.endOfInput(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .end_of_command => |end| { - if (@hasDecl(T, "endOfCommand")) { - try self.handler.endOfCommand(end.exit_code); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .report_pwd => |v| { - if (@hasDecl(T, "reportPwd")) { - try self.handler.reportPwd(v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .mouse_shape => |v| { - if (@hasDecl(T, "setMouseShape")) { - const shape = MouseShape.fromString(v.value) orelse { - log.warn("unknown cursor shape: {s}", .{v.value}); - return; - }; - - try self.handler.setMouseShape(shape); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .report_color => |v| { - if (@hasDecl(T, "reportColor")) { - try self.handler.reportColor(v.kind, v.terminator); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .set_color => |v| { - if (@hasDecl(T, "setColor")) { - try self.handler.setColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .reset_color => |v| { - if (@hasDecl(T, "resetColor")) { - try self.handler.resetColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .show_desktop_notification => |v| { - if (@hasDecl(T, "showDesktopNotification")) { - try self.handler.showDesktopNotification(v.title, v.body); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - } - - // Fall through for when we don't have a handler. - if (@hasDecl(T, "oscUnimplemented")) { - try self.handler.oscUnimplemented(cmd); - } else { - log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); - } - } - - fn configureCharset( - self: *Self, - intermediates: []const u8, - set: charsets.Charset, - ) !void { - if (intermediates.len != 1) { - log.warn("invalid charset intermediate: {any}", .{intermediates}); - return; - } - - const slot: charsets.Slots = switch (intermediates[0]) { - // TODO: support slots '-', '.', '/' - - '(' => .G0, - ')' => .G1, - '*' => .G2, - '+' => .G3, - else => { - log.warn("invalid charset intermediate: {any}", .{intermediates}); - return; - }, - }; - - if (@hasDecl(T, "configureCharset")) { - try self.handler.configureCharset(slot, set); - return; - } - - log.warn("unimplemented configureCharset callback slot={} set={}", .{ - slot, - set, - }); - } - - fn escDispatch( - self: *Self, - action: Parser.Action.ESC, - ) !void { - switch (action.final) { - // Charsets - 'B' => try self.configureCharset(action.intermediates, .ascii), - 'A' => try self.configureCharset(action.intermediates, .british), - '0' => try self.configureCharset(action.intermediates, .dec_special), - - // DECSC - Save Cursor - '7' => if (@hasDecl(T, "saveCursor")) switch (action.intermediates.len) { - 0 => try self.handler.saveCursor(), - else => { - log.warn("invalid command: {}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {}", .{action}), - - '8' => blk: { - switch (action.intermediates.len) { - // DECRC - Restore Cursor - 0 => if (@hasDecl(T, "restoreCursor")) { - try self.handler.restoreCursor(); - break :blk {}; - } else log.warn("unimplemented restore cursor callback: {}", .{action}), - - 1 => switch (action.intermediates[0]) { - // DECALN - Fill Screen with E - '#' => if (@hasDecl(T, "decaln")) { - try self.handler.decaln(); - break :blk {}; - } else log.warn("unimplemented ESC callback: {}", .{action}), - - else => {}, - }, - - else => {}, // fall through - } - - log.warn("unimplemented ESC action: {}", .{action}); - }, - - // IND - Index - 'D' => if (@hasDecl(T, "index")) switch (action.intermediates.len) { - 0 => try self.handler.index(), - else => { - log.warn("invalid index command: {}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {}", .{action}), - - // NEL - Next Line - 'E' => if (@hasDecl(T, "nextLine")) switch (action.intermediates.len) { - 0 => try self.handler.nextLine(), - else => { - log.warn("invalid next line command: {}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {}", .{action}), - - // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{action}), - - // RI - Reverse Index - 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { - 0 => try self.handler.reverseIndex(), - else => { - log.warn("invalid reverse index command: {}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {}", .{action}), - - // SS2 - Single Shift 2 - 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, true), - else => { - log.warn("invalid single shift 2 command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // SS3 - Single Shift 3 - 'O' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, true), - else => { - log.warn("invalid single shift 3 command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes")) { - try self.handler.deviceAttributes(.primary, &.{}); - } else log.warn("unimplemented ESC callback: {}", .{action}), - - // RIS - Full Reset - 'c' => if (@hasDecl(T, "fullReset")) switch (action.intermediates.len) { - 0 => try self.handler.fullReset(), - else => { - log.warn("invalid full reset command: {}", .{action}); - return; - }, - } else log.warn("unimplemented ESC callback: {}", .{action}), - - // LS2 - Locking Shift 2 - 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, false), - else => { - log.warn("invalid single shift 2 command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // LS3 - Locking Shift 3 - 'o' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, false), - else => { - log.warn("invalid single shift 3 command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // LS1R - Locking Shift 1 Right - '~' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G1, false), - else => { - log.warn("invalid locking shift 1 right command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // LS2R - Locking Shift 2 Right - '}' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G2, false), - else => { - log.warn("invalid locking shift 2 right command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // LS3R - Locking Shift 3 Right - '|' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G3, false), - else => { - log.warn("invalid locking shift 3 right command: {}", .{action}); - return; - }, - } else log.warn("unimplemented invokeCharset: {}", .{action}), - - // Set application keypad mode - '=' => if (@hasDecl(T, "setMode")) { - try self.handler.setMode(.keypad_keys, true); - } else log.warn("unimplemented setMode: {}", .{action}), - - // Reset application keypad mode - '>' => if (@hasDecl(T, "setMode")) { - try self.handler.setMode(.keypad_keys, false); - } else log.warn("unimplemented setMode: {}", .{action}), - - else => if (@hasDecl(T, "escUnimplemented")) - try self.handler.escUnimplemented(action) - else - log.warn("unimplemented ESC action: {}", .{action}), - - // Sets ST (string terminator). We don't have to do anything - // because our parser always accepts ST. - '\\' => {}, - } - } - }; -} - -test "stream: print" { - const H = struct { - c: ?u21 = 0, - - pub fn print(self: *@This(), c: u21) !void { - self.c = c; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.next('x'); - try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); -} - -test "simd: print invalid utf-8" { - const H = struct { - c: ?u21 = 0, - - pub fn print(self: *@This(), c: u21) !void { - self.c = c; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice(&.{0xFF}); - try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); -} - -test "simd: complete incomplete utf-8" { - const H = struct { - c: ?u21 = null, - - pub fn print(self: *@This(), c: u21) !void { - self.c = c; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice(&.{0xE0}); // 3 byte - try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0xA0}); // still incomplete - try testing.expect(s.handler.c == null); - try s.nextSlice(&.{0x80}); - try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); -} - -test "stream: cursor right (CUF)" { - const H = struct { - amount: u16 = 0, - - pub fn setCursorRight(self: *@This(), v: u16) !void { - self.amount = v; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[C"); - try testing.expectEqual(@as(u16, 1), s.handler.amount); - - try s.nextSlice("\x1B[5C"); - try testing.expectEqual(@as(u16, 5), s.handler.amount); - - s.handler.amount = 0; - try s.nextSlice("\x1B[5;4C"); - try testing.expectEqual(@as(u16, 0), s.handler.amount); -} - -test "stream: dec set mode (SM) and reset mode (RM)" { - const H = struct { - mode: modes.Mode = @as(modes.Mode, @enumFromInt(1)), - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = @as(modes.Mode, @enumFromInt(1)); - if (v) self.mode = mode; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[?6h"); - try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); - - try s.nextSlice("\x1B[?6l"); - try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); -} - -test "stream: ansi set mode (SM) and reset mode (RM)" { - const H = struct { - mode: ?modes.Mode = null, - - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = null; - if (v) self.mode = mode; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[4h"); - try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); - - try s.nextSlice("\x1B[4l"); - try testing.expect(s.handler.mode == null); -} - -test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { - const H = struct { - mode: ?modes.Mode = null, - - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = null; - if (v) self.mode = mode; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[6h"); - try testing.expect(s.handler.mode == null); - - try s.nextSlice("\x1B[6l"); - try testing.expect(s.handler.mode == null); -} - -test "stream: restore mode" { - const H = struct { - const Self = @This(); - called: bool = false, - - pub fn setTopAndBottomMargin(self: *Self, t: u16, b: u16) !void { - _ = t; - _ = b; - self.called = true; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - for ("\x1B[?42r") |c| try s.next(c); - try testing.expect(!s.handler.called); -} - -test "stream: pop kitty keyboard with no params defaults to 1" { - const H = struct { - const Self = @This(); - n: u16 = 0, - - pub fn popKittyKeyboard(self: *Self, n: u16) !void { - self.n = n; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - for ("\x1B[2s"); - try testing.expect(s.handler.escape == null); - - try s.nextSlice("\x1B[>s"); - try testing.expect(s.handler.escape.? == false); - - try s.nextSlice("\x1B[>0s"); - try testing.expect(s.handler.escape.? == false); - - try s.nextSlice("\x1B[>1s"); - try testing.expect(s.handler.escape.? == true); -} - -test "stream: change window title with invalid utf-8" { - const H = struct { - seen: bool = false, - - pub fn changeWindowTitle(self: *@This(), title: []const u8) !void { - _ = title; - - self.seen = true; - } - }; - - { - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1b]2;abc\x1b\\"); - try testing.expect(s.handler.seen); - } - - { - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1b]2;abc\xc0\x1b\\"); - try testing.expect(!s.handler.seen); - } -} - -test "stream: insert characters" { - const H = struct { - const Self = @This(); - called: bool = false, - - pub fn insertBlanks(self: *Self, v: u16) !void { - _ = v; - self.called = true; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - for ("\x1B[42@") |c| try s.next(c); - try testing.expect(s.handler.called); - - s.handler.called = false; - for ("\x1B[?42@") |c| try s.next(c); - try testing.expect(!s.handler.called); -} - -test "stream: SCOSC" { - const H = struct { - const Self = @This(); - called: bool = false, - - pub fn setLeftAndRightMargin(self: *Self, left: u16, right: u16) !void { - _ = self; - _ = left; - _ = right; - @panic("bad"); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { - self.called = true; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - for ("\x1B[s") |c| try s.next(c); - try testing.expect(s.handler.called); -} - -test "stream: SCORC" { - const H = struct { - const Self = @This(); - called: bool = false, - - pub fn restoreCursor(self: *Self) !void { - self.called = true; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - for ("\x1B[u") |c| try s.next(c); - try testing.expect(s.handler.called); -} - -test "stream: too many csi params" { - const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; - _ = self; - unreachable; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); -} - -test "stream: csi param too long" { - const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; - _ = self; - } - }; - - var s: Stream(H) = .{ .handler = .{} }; - try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); -} diff --git a/src/terminal-old/wasm.zig b/src/terminal-old/wasm.zig deleted file mode 100644 index 3450a6829d..0000000000 --- a/src/terminal-old/wasm.zig +++ /dev/null @@ -1,32 +0,0 @@ -// This is the C-ABI API for the terminal package. This isn't used -// by other Zig programs but by C or WASM interfacing. -// -// NOTE: This is far, far from complete. We did a very minimal amount to -// prove that compilation works, but we haven't completed coverage yet. - -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const Terminal = @import("main.zig").Terminal; -const wasm = @import("../os/wasm.zig"); -const alloc = wasm.alloc; - -export fn terminal_new(cols: usize, rows: usize) ?*Terminal { - const term = Terminal.init(alloc, cols, rows) catch return null; - const result = alloc.create(Terminal) catch return null; - result.* = term; - return result; -} - -export fn terminal_free(ptr: ?*Terminal) void { - if (ptr) |v| { - v.deinit(alloc); - alloc.destroy(v); - } -} - -export fn terminal_print(ptr: ?*Terminal, char: u32) void { - if (ptr) |t| { - t.print(@intCast(char)) catch return; - } -} diff --git a/src/terminal-old/x11_color.zig b/src/terminal-old/x11_color.zig deleted file mode 100644 index 9e4eda86bd..0000000000 --- a/src/terminal-old/x11_color.zig +++ /dev/null @@ -1,62 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const RGB = @import("color.zig").RGB; - -/// The map of all available X11 colors. -pub const map = colorMap() catch @compileError("failed to parse rgb.txt"); - -fn colorMap() !type { - @setEvalBranchQuota(100_000); - - const KV = struct { []const u8, RGB }; - - // The length of our data is the number of lines in the rgb file. - const len = std.mem.count(u8, data, "\n"); - var kvs: [len]KV = undefined; - - // Parse the line. This is not very robust parsing, because we expect - // a very exact format for rgb.txt. However, this is all done at comptime - // so if our data is bad, we should hopefully get an error here or one - // of our unit tests will catch it. - var iter = std.mem.splitScalar(u8, data, '\n'); - var i: usize = 0; - while (iter.next()) |line| { - if (line.len == 0) continue; - const r = try std.fmt.parseInt(u8, std.mem.trim(u8, line[0..3], " "), 10); - const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10); - const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10); - const name = std.mem.trim(u8, line[12..], " \t\n"); - kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } }; - i += 1; - } - assert(i == len); - - return std.ComptimeStringMapWithEql( - RGB, - kvs, - std.comptime_string_map.eqlAsciiIgnoreCase, - ); -} - -/// This is the rgb.txt file from the X11 project. This was last sourced -/// from this location: https://gitlab.freedesktop.org/xorg/app/rgb -/// This data is licensed under the MIT/X11 license while this Zig file is -/// licensed under the same license as Ghostty. -const data = @embedFile("res/rgb.txt"); - -test { - const testing = std.testing; - try testing.expectEqual(null, map.get("nosuchcolor")); - try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, map.get("white").?); - try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("medium spring green")); - try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("ForestGreen")); - try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("FoReStGReen")); - try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, map.get("black")); - try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, map.get("red")); - try testing.expectEqual(RGB{ .r = 0, .g = 255, .b = 0 }, map.get("green")); - try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 255 }, map.get("blue")); - try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, map.get("white")); - try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, map.get("lawngreen")); - try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen")); - try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen")); -} From e639ca1d1f12702c88500c87b6d08fe71b7219e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 Mar 2024 16:16:51 -0700 Subject: [PATCH 427/428] ci: try namespace again --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 48d338b63f..f7b26e5b93 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -8,7 +8,7 @@ name: Release PR jobs: build-macos: - runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest + runs-on: namespace-profile-ghostty-macos timeout-minutes: 90 steps: - name: Checkout code From c2053cba9819f640c895b14f60edc6c66a2d3e47 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 Mar 2024 19:59:20 -0700 Subject: [PATCH 428/428] ci: release tip moves to namespace --- .github/workflows/release-tip.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 837a9c7eb9..a8723d4285 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -32,11 +32,8 @@ jobs: ) }} - runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest + runs-on: namespace-profile-ghostty-macos timeout-minutes: 90 - env: - # Needed for macos SDK - AGREE: "true" steps: - name: Checkout code uses: actions/checkout@v4