Repository navigation
formatter: blank cells carry only across a joined wrap, not after a line break #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
19ab1cb
406763e
7711946
3c717e5
b16606a
93b6150
934f299
7322039
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1810,7 +1810,7 @@ fn printWrap(self: *Terminal) !void { | |
| const old_semantic_clear = cursor.semantic_content_clear_eol; | ||
|
|
||
| // Move to the next line | ||
| try self.index(); | ||
| try self.indexOpts(true); | ||
| self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); | ||
|
|
||
| // Our pointer should never move | ||
|
|
@@ -2384,6 +2384,12 @@ pub fn tabReset(self: *Terminal) void { | |
| /// | ||
| /// This unsets the pending wrap state without wrapping. | ||
| pub fn index(self: *Terminal) !void { | ||
| return self.indexOpts(false); | ||
| } | ||
|
|
||
| /// index(), and for `soft_wrap` (printWrap) the cursor row keeps the wrap | ||
| /// printWrap set on it: the row it wraps into is the one the scroll makes. | ||
| fn indexOpts(self: *Terminal, comptime soft_wrap: bool) !void { | ||
| const screen: *Screen = self.screens.active; | ||
|
|
||
| // Unset pending wrap state | ||
|
|
@@ -2443,6 +2449,12 @@ pub fn index(self: *Terminal) !void { | |
| screen.kitty_images.dirty = true; | ||
| } | ||
|
|
||
| // A linefeed at a bottom margin moves the cursor row up and away | ||
| // from the row below the region: a soft wrap from it into that row | ||
| // no longer joins one line. printWrap's own wrap goes into the new | ||
| // row the scroll makes, so it stays. | ||
| if (!soft_wrap) self.breakWrapAtBottomMargin(); | ||
|
|
||
| // If our scrolling region is at the top, we create scrollback, | ||
| // but only if our screen retains scrollback. If our screen | ||
| // doesn't retain scrollback (e.g. the alternate screen) then | ||
|
|
@@ -2508,6 +2520,16 @@ pub fn index(self: *Terminal) !void { | |
| } | ||
| } | ||
|
|
||
| /// Before a scroll that moves the cursor row (the bottom of a full-width | ||
| /// region with rows below it) up: break the cursor row's soft wrap into the | ||
| /// row below the region, which does not move. | ||
| fn breakWrapAtBottomMargin(self: *Terminal) void { | ||
| if (self.scrolling_region.bottom + 1 >= self.rows or | ||
| self.scrolling_region.left != 0 or | ||
| self.scrolling_region.right != self.cols - 1) return; | ||
| self.screens.active.cursorResetWrap(); | ||
| } | ||
|
|
||
| /// The operation when we have Kitty image placements during index. | ||
| /// Split out of index() so its hot paths don't carry the adjustment | ||
| /// state in their stack frame, which measurably slows the scroll | ||
|
|
@@ -2795,6 +2817,7 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { | |
| // Move our cursor to the bottom of the scroll region so we can | ||
| // use the cursorScrollAbove function to create scrollback | ||
| self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); | ||
| self.breakWrapAtBottomMargin(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leave wrap state unchanged when If a caller passes
|
||
| for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); | ||
| return; | ||
| } | ||
|
|
@@ -2995,6 +3018,18 @@ fn invalidateFullWidthRowRange( | |
| } | ||
| } | ||
|
|
||
| /// After insertLines or deleteLines with a full-width scroll region: the | ||
| /// rows from `top` to the region bottom moved, the rows above and below | ||
| /// them did not, so no soft wrap may join a row outside to one inside. | ||
| fn breakWrapAtLineEdges(self: *Terminal, top: size.CellCountInt) void { | ||
| const screen: *Screen = self.screens.active; | ||
| screen.breakWrapAbove(screen.pages.pin(.{ .active = .{ .y = top } }).?); | ||
| const bottom = self.scrolling_region.bottom; | ||
| if (bottom + 1 < self.rows) screen.breakWrapAbove( | ||
| screen.pages.pin(.{ .active = .{ .y = bottom + 1 } }).?, | ||
| ); | ||
| } | ||
|
|
||
| // TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical, | ||
| // the majority of their logic can (and should) be abstracted in to | ||
| // a single shared helper function, probably on `Screen` not here. | ||
|
|
@@ -3174,6 +3209,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { | |
| // Move our pin up to the next row. | ||
| if (cur_p.up(1)) |p| cur_p.* = p; | ||
| } | ||
|
|
||
| if (!left_right) self.breakWrapAtLineEdges(start_y); | ||
| } | ||
|
|
||
| /// Removes amount lines from the current cursor row down. The remaining lines | ||
|
|
@@ -3342,6 +3379,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { | |
| // Move our pin down to the next row. | ||
| if (cur_p.down(1)) |p| cur_p.* = p; | ||
| } | ||
|
|
||
| if (!left_right) self.breakWrapAtLineEdges(start_y); | ||
| } | ||
|
|
||
| /// Inserts spaces at current cursor position moving existing cell contents | ||
|
|
@@ -16908,6 +16947,171 @@ test "Terminal: scroll region linefeed recycled row has default metadata" { | |
| } | ||
| } | ||
|
|
||
| // A scroll of a scroll region moves the rows inside it and keeps the rows | ||
| // outside it. A soft wrap between a row outside and a row inside then joins | ||
| // two rows that no longer hold one line: the outside row's wrap flag (above | ||
| // the region) or wrap_continuation flag (below it) is stale. Reflow and the | ||
| // unwrapping formatter read those flags, so each edge must be broken. | ||
| fn testRowFlags(t: *Terminal, y: u32) struct { wrap: bool, cont: bool } { | ||
| const row = t.screens.active.pages.getCell(.{ .active = .{ .y = y } }).?.row; | ||
| return .{ .wrap = row.wrap, .cont = row.wrap_continuation }; | ||
| } | ||
|
|
||
| test "Terminal: index at the scroll region bottom breaks the wrap into the row below" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 2 soft-wraps into row 3. | ||
| t.setCursorPos(3, 1); | ||
| try t.printString("abcdefg"); | ||
| try testing.expect(testRowFlags(&t, 3).cont); | ||
|
|
||
| // DECSTBM rows 2-3 (rows 1-2 here), then a linefeed at its bottom: | ||
| // row 2 becomes a blank row and row 3, outside the region, stays. | ||
| t.setTopAndBottomMargin(2, 3); | ||
| t.setCursorPos(3, 1); | ||
| try t.linefeed(); | ||
|
|
||
| // Row 2's content moved to row 1; row 3 did not move. | ||
| try testing.expect(!testRowFlags(&t, 1).wrap); | ||
| try testing.expect(!testRowFlags(&t, 2).wrap); | ||
| try testing.expect(!testRowFlags(&t, 2).cont); | ||
| try testing.expect(!testRowFlags(&t, 3).cont); | ||
| } | ||
|
|
||
| test "Terminal: print wrap at the scroll region bottom keeps its own wrap and breaks the one below" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 2 soft-wraps into row 3. | ||
| t.setCursorPos(3, 1); | ||
| try t.printString("abcdefg"); | ||
| try testing.expect(testRowFlags(&t, 3).cont); | ||
|
|
||
| // DECSTBM rows 2-3 (rows 1-2 here). A wide character at row 2's last | ||
| // column does not fit, so row 2 gets a spacer head and soft-wraps; the | ||
| // wrap scrolls the region and row 2 moves to row 1. | ||
| t.setTopAndBottomMargin(2, 3); | ||
| t.setCursorPos(3, 5); | ||
| try t.print(0x6F22); // 漢 | ||
|
|
||
| // Row 1 (the moved row) wraps into the new row 2, with its spacer head. | ||
| try testing.expect(testRowFlags(&t, 1).wrap); | ||
| try testing.expect(testRowFlags(&t, 2).cont); | ||
| { | ||
| const cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 4, .y = 1 } }).?.cell; | ||
| try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); | ||
| } | ||
| // Row 3 does not move and no longer continues anything. | ||
| try testing.expect(!testRowFlags(&t, 3).cont); | ||
| } | ||
|
|
||
| test "Terminal: index in a scroll region breaks the wrap across the region top" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 0 soft-wraps into row 1. | ||
| t.setCursorPos(1, 1); | ||
| try t.printString("abcdefg"); | ||
| try testing.expect(testRowFlags(&t, 0).wrap); | ||
|
|
||
| // DECSTBM rows 2-4 (rows 1-3 here): row 1 is discarded and row 0, | ||
| // outside the region, stays. | ||
| t.setTopAndBottomMargin(2, 4); | ||
| t.setCursorPos(4, 1); | ||
| try t.linefeed(); | ||
|
|
||
| try testing.expect(!testRowFlags(&t, 0).wrap); | ||
| try testing.expect(!testRowFlags(&t, 1).cont); | ||
| } | ||
|
|
||
| test "Terminal: index at a bottom margin with scrollback breaks the wrap into the row below" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 1 soft-wraps into row 2. | ||
| t.setCursorPos(2, 1); | ||
| try t.printString("abcdefg"); | ||
| try testing.expect(testRowFlags(&t, 2).cont); | ||
|
|
||
| // DECSTBM rows 1-2 (rows 0-1 here) scrolls into the scrollback; row 1 | ||
| // becomes a blank row and row 2, outside the region, stays. | ||
| t.setTopAndBottomMargin(1, 2); | ||
| t.setCursorPos(2, 1); | ||
| try t.linefeed(); | ||
|
|
||
| // Row 1's content moved to row 0; row 2 did not move. | ||
| try testing.expect(!testRowFlags(&t, 0).wrap); | ||
| try testing.expect(!testRowFlags(&t, 1).wrap); | ||
| try testing.expect(!testRowFlags(&t, 2).cont); | ||
| } | ||
|
|
||
| test "Terminal: scrollUp at a bottom margin breaks the wrap into the row below" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 2 soft-wraps into row 3. | ||
| t.setCursorPos(3, 1); | ||
| try t.printString("abcdefg"); | ||
|
|
||
| // DECSTBM rows 1-3 (rows 0-2 here), SU 1: row 2's content moves to | ||
| // row 1 and row 3 does not move. | ||
| t.setTopAndBottomMargin(1, 3); | ||
| try t.scrollUp(1); | ||
|
|
||
| try testing.expect(!testRowFlags(&t, 1).wrap); | ||
| try testing.expect(!testRowFlags(&t, 3).cont); | ||
| } | ||
|
|
||
| test "Terminal: deleteLines breaks the wrap across both region edges" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 0 soft-wraps into row 1 and row 3 soft-wraps into row 4. | ||
| t.setCursorPos(1, 1); | ||
| try t.printString("abcdefg"); | ||
| t.setCursorPos(4, 1); | ||
| try t.printString("hijklmn"); | ||
|
|
||
| // DECSTBM rows 2-4 (rows 1-3 here), delete the region's top line. | ||
| t.setTopAndBottomMargin(2, 4); | ||
| t.setCursorPos(2, 1); | ||
| t.deleteLines(1); | ||
|
|
||
| try testing.expect(!testRowFlags(&t, 0).wrap); | ||
| try testing.expect(!testRowFlags(&t, 1).cont); | ||
| try testing.expect(!testRowFlags(&t, 3).wrap); | ||
| try testing.expect(!testRowFlags(&t, 4).cont); | ||
| } | ||
|
|
||
| test "Terminal: insertLines breaks the wrap across both region edges" { | ||
| const alloc = testing.allocator; | ||
| var t = try init(testing.io, alloc, .{ .cols = 5, .rows = 6 }); | ||
| defer t.deinit(alloc); | ||
|
|
||
| // Row 0 soft-wraps into row 1 and row 3 soft-wraps into row 4. | ||
| t.setCursorPos(1, 1); | ||
| try t.printString("abcdefg"); | ||
| t.setCursorPos(4, 1); | ||
| try t.printString("hijklmn"); | ||
|
|
||
| // DECSTBM rows 2-4 (rows 1-3 here), insert a line at the region's top. | ||
| t.setTopAndBottomMargin(2, 4); | ||
| t.setCursorPos(2, 1); | ||
| t.insertLines(1); | ||
|
|
||
| try testing.expect(!testRowFlags(&t, 0).wrap); | ||
| try testing.expect(!testRowFlags(&t, 1).cont); | ||
| try testing.expect(!testRowFlags(&t, 3).wrap); | ||
| try testing.expect(!testRowFlags(&t, 4).cont); | ||
| } | ||
|
|
||
| test "Terminal: alt screen scroll up recycled row has default metadata" { | ||
| const alloc = testing.allocator; | ||
| const io_impl = testing.io; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Break the wrap at active row zero.
When the region starts at row zero, this guard skips the top edge. If scrollback contains a wrapped row above the discarded top row, it remains joined to the row moved into row zero. Copying the screen then joins text that was separated by the discarded row.
Call
breakWrapAbovefor the region top unconditionally. The helper already handles both a scrollback row and the absence of a preceding row.