diff --git a/src/config/Config.zig b/src/config/Config.zig index 8ba1e47db..a9aaf8f86 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2006,7 +2006,9 @@ keybind: Keybinds = .{}, @"clipboard-write": ClipboardAccess = .allow, /// Trims trailing whitespace on data that is copied to the clipboard. This does -/// not affect data sent to the clipboard via `clipboard-write`. +/// not affect data sent to the clipboard via `clipboard-write`. This only +/// applies to trailing whitespace on lines that have other characters. +/// Completely blank lines always have their whitespace trimmed. @"clipboard-trim-trailing-spaces": bool = true, /// Require confirmation before pasting text that appears unsafe. This helps diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 81d6d4ab6..486c4f384 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -13,6 +13,7 @@ const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); +const ScreenFormatter = @import("formatter.zig").ScreenFormatter; const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -2170,163 +2171,51 @@ pub const SelectionString = struct { /// 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). +/// +/// For more flexibility, use a ScreenFormatter directly. 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) = .empty; - defer strbuilder.deinit(alloc); + // We'll use this as our buffer to build our string. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.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) .empty else null; - defer if (mapbuilder) |*b| b.deinit(alloc); + // Create a formatter and use that to emit our text. + var formatter: ScreenFormatter = .init( + self, + .{ + .emit = .plain, + .unwrap = true, + .trim = opts.trim, + }, + ); + formatter.content = .{ .selection = opts.sel }; - const sel_ordered = opts.sel.ordered(self, .forward); - const sel_start: Pin = start: { - var start: Pin = sel_ordered.start(); - const cell = start.rowAndCell().cell; - if (cell.wide == .spacer_tail) start.x -= 1; - break :start start; - }; - const sel_end: Pin = end: { - var end: Pin = sel_ordered.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; + // If we have a string map, we need to set that up. + var pins: std.ArrayList(Pin) = .empty; + defer pins.deinit(alloc); + if (opts.map != null) formatter.pin_map = .{ + .alloc = alloc, + .map = &pins, }; - var page_it = sel_start.pageIterator(.right_down, sel_end); - while (page_it.next()) |chunk| { - const rows = chunk.rows(); - for (rows, chunk.start.., 0..) |row, y, row_i| { - const cells_ptr = row.cells.ptr(chunk.node.data.memory); + // Emit + try formatter.format(&aw.writer); - const start_x = if ((row_i == 0 or sel_ordered.rectangle) and - sel_start.node == chunk.node) - sel_start.x - else - 0; - const end_x = if ((row_i == rows.len - 1 or sel_ordered.rectangle) and - sel_end.node == chunk.node) - sel_end.x + 1 - else - self.pages.cols; - - const cells = cells_ptr[start_x..end_x]; - for (cells, start_x..) |*cell, x| { - // 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(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - if (cell.hasGrapheme()) { - const cps = chunk.node.data.lookupGrapheme(cell).?; - for (cps) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - } - } - - const is_final_row = chunk.node == sel_end.node and y == sel_end.y; - - if (!is_final_row and - (!row.wrap or sel_ordered.rectangle)) - { - try strbuilder.append(alloc, '\n'); - if (mapbuilder) |*b| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = chunk.node.data.size.cols - 1, - }); - } - } + // Build our final text and if we have a string map set that up. + const text = try aw.toOwnedSliceSentinel(0); + errdefer alloc.free(text); + if (opts.map) |map| { + map.* = .{ + .string = try alloc.dupeZ(u8, text), + .map = try pins.toOwnedSlice(alloc), + }; } + errdefer if (opts.map) |m| m.deinit(alloc); - 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(alloc); - defer strclone.deinit(alloc); - const str = try strclone.toOwnedSliceSentinel(alloc, 0); - errdefer alloc.free(str); - const map = try b.toOwnedSlice(alloc); - 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. - if (opts.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(alloc, '\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(alloc, 0); - errdefer alloc.free(string); - - return string; + return text; } pub const SelectLine = struct { @@ -8384,7 +8273,7 @@ test "Screen: selectionString trim empty line" { .trim = false, }); defer alloc.free(contents); - const expected = "1AB \n \n2EF"; + const expected = "1AB \n\n2EF"; try testing.expectEqualStrings(expected, contents); } } diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 136ff80bb..cd4b76340 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -16,12 +16,9 @@ const Row = @import("page.zig").Row; const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; -// TODO: -// - Rectangular selection - /// Formats available. pub const Format = enum { - /// Plain text + /// Plain text. plain, /// Include VT sequences to preserve colors, styles, URLs, etc. @@ -31,6 +28,9 @@ pub const Format = enum { /// vary based on the formatter and you should see the docs. For example, /// PageFormatter with VT will emit SGR sequences with palette indices, /// not the color itself. + /// + /// For VT, newlines will be emitted as `\r\n` so that the cursor properly + /// moves back to the beginning prior emitting follow-up lines. vt, pub fn styled(self: Format) bool { @@ -380,6 +380,7 @@ pub const ScreenFormatter = struct { none, /// Emit the content specified by the selection. Null for all. + /// The selection is inclusive on both ends. selection: ?Selection, }; @@ -471,6 +472,7 @@ pub const ScreenFormatter = struct { if (selection_) |sel| { list_formatter.top_left = sel.topLeft(self.screen); list_formatter.bottom_right = sel.bottomRight(self.screen); + list_formatter.rectangle = sel.rectangle; } try list_formatter.format(writer); }, @@ -626,6 +628,10 @@ pub const PageListFormatter = struct { top_left: ?PageList.Pin, bottom_right: ?PageList.Pin, + /// If true, the boundaries define a rectangle selection where start_x + /// and end_x apply to every row, not just the first and last. + rectangle: bool, + /// If non-null, then `map` will contain the Pin of every byte /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. @@ -642,6 +648,7 @@ pub const PageListFormatter = struct { .opts = opts, .top_left = null, .bottom_right = null, + .rectangle = false, .pin_map = null, }; } @@ -660,17 +667,24 @@ pub const PageListFormatter = struct { var page_state: ?PageFormatter.TrailingState = null; var iter = tl.pageIterator(.right_down, br); while (iter.next()) |chunk| { + assert(chunk.start < chunk.end); + assert(chunk.end > 0); + var formatter: PageFormatter = .init(&chunk.node.data, self.opts); formatter.start_y = chunk.start; - formatter.end_y = chunk.end; + formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; + formatter.rectangle = self.rectangle; - // Apply start_x if this is the first chunk - if (chunk.node == tl.node) formatter.start_x = tl.x; - - // Apply end_x if this is the last chunk and it ends at br.y - if (chunk.node == br.node and - formatter.end_y == br.y + 1) formatter.end_x = br.x + 1; + // For rectangle selection, apply start_x and end_x to all chunks + if (self.rectangle) { + formatter.start_x = tl.x; + formatter.end_x = br.x; + } else { + // Otherwise only on the first/last, respectively. + if (chunk.node == tl.node) formatter.start_x = tl.x; + if (chunk.node == br.node) formatter.end_x = br.x; + } // If we're tracking pins, then we setup a point map for the // page formatter (cause it can't track pins). And then we convert @@ -714,13 +728,25 @@ pub const PageFormatter = struct { /// then it will be the full width. If end y is not given then it will be /// the full height. /// + /// The start and end are both inclusive, so equal values will still + /// return a non-empty result (i.e. a single cell or row). + /// /// The start x is considered the X in the first row and end X is /// X in the final row. This isn't a rectangle selection by default. + /// + /// If start X falls on the second column of a wide character, then + /// the entire character will be included (as if you specified the + /// previous column). start_x: size.CellCountInt, start_y: size.CellCountInt, end_x: ?size.CellCountInt, end_y: ?size.CellCountInt, + /// If true, the start x/y and end x/y define a rectangle selection. + /// In this case, the boundaries will apply to every row, not just + /// the first and last. + rectangle: bool, + /// If non-null, then `map` will contain the x/y coordinate of every /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. @@ -756,6 +782,7 @@ pub const PageFormatter = struct { .start_y = 0, .end_x = null, .end_y = null, + .rectangle = false, .point_map = null, .trailing_state = null, }; @@ -785,29 +812,52 @@ pub const PageFormatter = struct { } } - // Setup our starting row and perform some validation for overflows. - const start_y: size.CellCountInt = self.start_y; - if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows; - if (start_y >= end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_y = @min(end_y_unclamped, self.page.size.rows); - // Setup our starting column and perform some validation for overflows. // Note: start_x only applies to the first row, end_x only applies to the last row. const start_x: size.CellCountInt = self.start_x; if (start_x >= self.page.size.cols) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols; - const end_x = @min(end_x_unclamped, self.page.size.cols); + const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols - 1; + var end_x = @min(end_x_unclamped, self.page.size.cols - 1); - // If we only have a single row, validate that start_x < end_x - if (start_y + 1 == end_y and start_x >= end_x) { + // Setup our starting row and perform some validation for overflows. + const start_y: size.CellCountInt = self.start_y; + if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows - 1; + if (start_y > end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; + var end_y = @min(end_y_unclamped, self.page.size.rows - 1); + + // Edge case: if our end x/y falls on a spacer head AND we're unwrapping, + // then we move the x/y to the start of the next row (if available). + if (self.opts.unwrap and !self.rectangle) { + const final_row = self.page.getRow(end_y); + const cells = self.page.getCells(final_row); + switch (cells[end_x].wide) { + .spacer_head => { + // Move to next row if available + // + // TODO: if unavailable, we should add to our trailing state + // + // so the pagelist formatter can be aware and maybe add + // another page + if (end_y < self.page.size.rows - 1) { + end_y += 1; + end_x = 0; + } + }, + + else => {}, + } + } + + // If we only have a single row, validate that start_x <= end_x + if (start_y == end_y and start_x > end_x) { return .{ .rows = blank_rows, .cells = blank_cells }; } // Our style for non-plain formats var style: Style = .{}; - for (start_y..end_y) |y_usize| { + for (start_y..end_y + 1) |y_usize| { const y: size.CellCountInt = @intCast(y_usize); const row: *Row = self.page.getRow(y); const cells: []const Cell = self.page.getCells(row); @@ -816,11 +866,33 @@ pub const PageFormatter = struct { // - First row: start_x to end of row (or end_x if single row) // - Last row: start of row to end_x // - Middle rows: full width - const is_first_row = (y == start_y); - const is_last_row = (y == end_y - 1); - const row_start_x: size.CellCountInt = if (is_first_row) start_x else 0; - const row_end_x: size.CellCountInt = if (is_last_row) end_x else self.page.size.cols; - const cells_subset = cells[row_start_x..row_end_x]; + const cells_subset, const row_start_x = cells_subset: { + // The end is always straightforward + const row_end_x: size.CellCountInt = if (self.rectangle or y == end_y) + end_x + 1 + else + self.page.size.cols; + + // The first we have to check if our start X falls on the + // tail of a wide character. + const row_start_x: size.CellCountInt = if (start_x > 0 and + (self.rectangle or y == start_y)) + start_x: { + break :start_x switch (cells[start_x].wide) { + // Include the prior cell to get the full wide char + .spacer_tail => start_x - 1, + + // If we're a spacer head on our first row then we + // skip this whole row. + .spacer_head => continue, + + .narrow, .wide => start_x, + }; + } else 0; + + const subset = cells[row_start_x..row_end_x]; + break :cells_subset .{ subset, row_start_x }; + }; // If this row is blank, accumulate to avoid a bunch of extra // work later. If it isn't blank, make sure we dump all our @@ -831,7 +903,12 @@ pub const PageFormatter = struct { } if (blank_rows > 0) { - for (0..blank_rows) |_| try writer.writeAll("\r\n"); + const sequence: []const u8 = switch (self.opts.emit) { + .plain => "\n", + .vt => "\r\n", + }; + + for (0..blank_rows) |_| try writer.writeAll(sequence); // \r and \n map to the row that ends with this newline. // If we're continuing (trailing state) then this will be @@ -847,7 +924,7 @@ pub const PageFormatter = struct { map.map.appendNTimes( map.alloc, .{ .x = start.x, .y = start.y }, - 2, // \r and \n + sequence.len, ) catch return error.WriteFailed; // All others have x = 0 since they reference their prior @@ -857,7 +934,7 @@ pub const PageFormatter = struct { map.map.appendNTimes( map.alloc, .{ .x = 0, .y = start.y + y_offset }, - 2, // \r and \n + sequence.len, ) catch return error.WriteFailed; } } @@ -1049,6 +1126,214 @@ test "Page plain single line" { ); } +test "Page plain single wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 3; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } +} + +test "Page plain single wide char soft-wrapped unwrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 3, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.opts.unwrap = true; + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Full string (ending on spacer head) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.end_x = 2; + formatter.end_y = 0; + defer { + formatter.end_x = null; + formatter.end_y = null; + } + + _ = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_y = 1; + formatter.start_x = 1; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } +} + test "Page plain multiline" { const testing = std.testing; const alloc = testing.allocator; @@ -1083,7 +1368,7 @@ test "Page plain multiline" { // Verify output const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1093,11 +1378,64 @@ test "Page plain multiline" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], + ); +} + +test "Page plain multiline rectangle" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 1; + formatter.end_x = 3; + formatter.rectangle = true; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("ell\norl", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // \n + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 1 }, + point_map.items[4 + i], ); } @@ -1135,7 +1473,7 @@ test "Page plain multi blank lines" { // Verify output const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", output); + try testing.expectEqualStrings("hello\n\n\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1145,15 +1483,12 @@ test "Page plain multi blank lines" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r after row 0 - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n after row 0 - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[7]); // \r after blank row 1 - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[8]); // \n after blank row 1 - try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[9]); // \r after blank row 2 - try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[10]); // \n after blank row 2 + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n after row 0 + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[6]); // \n after blank row 1 + try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[7]); // \n after blank row 2 for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 3 }, - point_map.items[11 + i], + point_map.items[8 + i], ); } @@ -1193,7 +1528,7 @@ test "Page plain trailing blank lines" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1203,11 +1538,10 @@ test "Page plain trailing blank lines" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1247,7 +1581,7 @@ test "Page plain trailing whitespace" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1257,11 +1591,10 @@ test "Page plain trailing whitespace" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1304,7 +1637,7 @@ test "Page plain trailing whitespace no trim" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello \r\nworld ", output); + try testing.expectEqualStrings("hello \nworld ", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 7), state.cells); @@ -1314,11 +1647,10 @@ test "Page plain trailing whitespace no trim" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \r - try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[9]); // \n + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \n for (0..7) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[10 + i], + point_map.items[9 + i], ); } @@ -1354,19 +1686,17 @@ test "Page plain with prior trailing state rows" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\r\n\r\nhello", output); + try testing.expectEqualStrings("\n\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 0 }, - point_map.items[4 + i], + point_map.items[2 + i], ); } @@ -1501,7 +1831,7 @@ test "Page plain soft-wrapped without unwrap" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd test", output); + try testing.expectEqualStrings("hello worl\nd test", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); @@ -1511,11 +1841,10 @@ test "Page plain soft-wrapped without unwrap" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..6) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); } @@ -1599,7 +1928,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", output); + try testing.expectEqualStrings("hello worl\nd this is\na test", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); @@ -1609,17 +1938,15 @@ test "Page plain soft-wrapped 3 lines without unwrap" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..9) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[21]); // \r - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[22]); // \n + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[20]); // \n for (0..6) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[23 + i], + point_map.items[21 + i], ); } @@ -1705,7 +2032,7 @@ test "Page plain start_y subset" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest", output); + try testing.expectEqualStrings("world\ntest", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); @@ -1715,11 +2042,10 @@ test "Page plain start_y subset" { Coordinate{ .x = @intCast(i), .y = 1 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n for (0..4) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1745,7 +2071,7 @@ test "Page plain end_y subset" { const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); - formatter.end_y = 2; + formatter.end_y = 1; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -1753,7 +2079,7 @@ test "Page plain end_y subset" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1763,11 +2089,10 @@ test "Page plain end_y subset" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1794,7 +2119,7 @@ test "Page plain start_y and end_y range" { var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; - formatter.end_y = 3; + formatter.end_y = 2; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -1802,7 +2127,7 @@ test "Page plain start_y and end_y range" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest", output); + try testing.expectEqualStrings("world\ntest", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); @@ -1812,11 +2137,10 @@ test "Page plain start_y and end_y range" { Coordinate{ .x = @intCast(i), .y = 1 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n for (0..4) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -2004,8 +2328,8 @@ test "Page plain end_x on last row only" { const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); - formatter.end_y = 3; - formatter.end_x = 6; + formatter.end_y = 2; + formatter.end_x = 4; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -2013,10 +2337,9 @@ test "Page plain end_x on last row only" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - // First two rows: full width, last row: up to end_x=6 - try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", output); + try testing.expectEqualStrings("first line\nsecond line\nthird", output); try testing.expectEqual(@as(usize, 1), state.rows); - try testing.expectEqual(@as(usize, 1), state.cells); + try testing.expectEqual(@as(usize, 0), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); @@ -2024,17 +2347,15 @@ test "Page plain end_x on last row only" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..11) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); - try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[23]); // \r - try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[24]); // \n + try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[22]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[25 + i], + point_map.items[23 + i], ); } @@ -2061,8 +2382,8 @@ test "Page plain start_x and end_x multiline" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; - formatter.end_y = 3; - formatter.end_x = 4; + formatter.end_y = 2; + formatter.end_x = 2; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -2072,10 +2393,10 @@ test "Page plain start_x and end_x multiline" { const output = builder.writer.buffered(); // First row: "world" (start_x=6 to end of row) // Second row: "test case" (full row) - // Third row: "foo " (start to end_x=4) - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); + // Third row: "foo" (start to end_x=2, inclusive) + try testing.expectEqualStrings("world\ntest case\nfoo", output); try testing.expectEqual(@as(usize, 1), state.rows); - try testing.expectEqual(@as(usize, 1), state.cells); + try testing.expectEqual(@as(usize, 0), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); @@ -2083,17 +2404,15 @@ test "Page plain start_x and end_x multiline" { Coordinate{ .x = @intCast(i + 6), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \n for (0..9) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[16]); // \r - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[17]); // \n + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[15]); // \n for (0..3) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[18 + i], + point_map.items[16 + i], ); } @@ -2200,7 +2519,7 @@ test "Page plain end_x less than start_x single row" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 10; - formatter.end_y = 1; + formatter.end_y = 0; formatter.end_x = 5; var point_map: std.ArrayList(Coordinate) = .empty; @@ -2338,19 +2657,17 @@ test "Page plain start_y and start_x zero uses trailing state" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 - try testing.expectEqualStrings("\r\n\r\nhello", output); + try testing.expectEqualStrings("\n\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 0 }, - point_map.items[4 + i], + point_map.items[2 + i], ); } @@ -2713,8 +3030,8 @@ test "PageList plain spanning two pages" { formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("page one\r\npage two", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("page one\npage two", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -2734,13 +3051,12 @@ test "PageList plain spanning two pages" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 9].node); // "page two" (8 chars) maps to last node for (0..8) |i| { - const idx = trimmed_count + 10 + i; + const idx = trimmed_count + 9 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } @@ -2780,8 +3096,8 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("hello worl\r\nd test", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("hello worl\nd test", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -2800,13 +3116,12 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { try testing.expectEqual(first_node, pin_map.items[idx].node); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 11].node); // "d test" (6 chars) maps to last node for (0..6) |i| { - const idx = trimmed_count + 12 + i; + const idx = trimmed_count + 11 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); } } @@ -2958,12 +3273,12 @@ test "PageList plain with x offset on single page" { var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; - formatter.bottom_right = .{ .node = node, .y = 2, .x = 3 }; + formatter.bottom_right = .{ .node = node, .y = 2, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); + try testing.expectEqualStrings("world\ntest case\nfoo", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3018,13 +3333,13 @@ test "PageList plain with x offset spanning two pages" { var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; - formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 3 }; + formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("world\r\nfoo", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("world\nfoo", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -3037,13 +3352,12 @@ test "PageList plain with x offset spanning two pages" { try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 6].node); // "foo" (3 chars) from last page for (0..3) |i| { - const idx = trimmed_count + 7 + i; + const idx = trimmed_count + 6 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } @@ -3120,7 +3434,7 @@ test "PageList plain with end_x only" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello world\r\ntes", output); + try testing.expectEqualStrings("hello world\ntes", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3132,18 +3446,145 @@ test "PageList plain with end_x only" { try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // \r\n + // \n try testing.expectEqual(node, pin_map.items[11].node); - try testing.expectEqual(node, pin_map.items[12].node); // "tes" (3 chars) on y=1 for (0..3) |i| { - try testing.expectEqual(node, pin_map.items[13 + i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[13 + i].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[13 + i].y); + try testing.expectEqual(node, pin_map.items[12 + i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[12 + i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[12 + i].y); } } +test "PageList plain rectangle basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\t ame + \\ipisc + \\usmod + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle with EOL" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\dolor + \\nsectetur + \\lit, sed do + \\or incididunt + \\ dolore + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle more complex with breaks" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 30, + .rows = 8, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore\r\n"); + try s.nextSlice("\r\n"); + try s.nextSlice("magna aliqua. Ut enim\r\n"); + try s.nextSlice("ad minim veniam, quis"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\elit, sed do + \\por incididunt + \\t dolore + \\ + \\a. Ut enim + \\niam, quis + ; + try testing.expectEqualStrings(expected, output); +} + test "TerminalFormatter plain no selection" { const testing = std.testing; const alloc = testing.allocator; @@ -3165,7 +3606,7 @@ test "TerminalFormatter plain no selection" { const formatter: TerminalFormatter = .init(&t, .plain); try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqualStrings("hello\nworld", builder.writer.buffered()); } test "TerminalFormatter vt with palette" { @@ -3305,7 +3746,7 @@ test "TerminalFormatter plain multiline with pin_map" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3316,12 +3757,11 @@ test "TerminalFormatter plain multiline with pin_map" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // "\r\n" maps to end of first line + // "\n" maps to end of first line try testing.expectEqual(node, pin_map.items[5].node); - try testing.expectEqual(node, pin_map.items[6].node); // "world" (5 chars) for (0..5) |i| { - const idx = 7 + i; + const idx = 6 + i; try testing.expectEqual(node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); @@ -3472,7 +3912,7 @@ test "Screen plain multiline" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3483,12 +3923,11 @@ test "Screen plain multiline" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // "\r\n" maps to end of first line + // "\n" maps to end of first line try testing.expectEqual(node, pin_map.items[5].node); - try testing.expectEqual(node, pin_map.items[6].node); // "world" (5 chars) for (0..5) |i| { - const idx = 7 + i; + const idx = 6 + i; try testing.expectEqual(node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y);