mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 08:46:08 +03:00
Merge pull request #2201 from qwerasd205/wide-boundary-conds
Wide cell boundary conditions in ECH & DCH + soft-wrap reset correctness
This commit is contained in:
@ -1041,6 +1041,41 @@ pub fn cursorMarkDirty(self: *Screen) void {
|
||||
self.cursor.page_pin.markDirty();
|
||||
}
|
||||
|
||||
/// Reset the cursor row's soft-wrap state and the cursor's pending wrap.
|
||||
/// Also handles clearing the spacer head on the cursor row and resetting
|
||||
/// the wrap_continuation flag on the next row if necessary.
|
||||
///
|
||||
/// NOTE(qwerasd): This method is not scrolling region aware, and cannot be
|
||||
/// since it's on Screen not Terminal. This needs to be addressed down the
|
||||
/// line. Not an extremely urgent issue since it's an edge case of an edge
|
||||
/// case, but not ideal.
|
||||
pub fn cursorResetWrap(self: *Screen) void {
|
||||
// Reset the cursor's pending wrap state
|
||||
self.cursor.pending_wrap = false;
|
||||
|
||||
const page_row = self.cursor.page_row;
|
||||
|
||||
if (!page_row.wrap) return;
|
||||
|
||||
// This row does not wrap and the next row is not wrapped to
|
||||
page_row.wrap = false;
|
||||
|
||||
if (self.cursor.page_pin.down(1)) |next_row| {
|
||||
next_row.rowAndCell().row.wrap_continuation = false;
|
||||
}
|
||||
|
||||
// If the last cell in the row is a spacer head we need to clear it.
|
||||
const cells = self.cursor.page_pin.cells(.all);
|
||||
const cell = cells[self.cursor.page_pin.page.data.size.cols - 1];
|
||||
if (cell.wide == .spacer_head) {
|
||||
self.clearCells(
|
||||
&self.cursor.page_pin.page.data,
|
||||
page_row,
|
||||
cells[self.cursor.page_pin.page.data.size.cols - 1 ..][0..1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@ -1282,6 +1317,134 @@ pub fn clearPrompt(self: *Screen) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up boundary conditions where a cell will become discontiguous with
|
||||
/// a neighboring cell because either one of them will be moved and/or cleared.
|
||||
///
|
||||
/// For performance reasons this is specialized to operate on the cursor row.
|
||||
///
|
||||
/// Handles the boundary between the cell at `x` and the cell at `x - 1`.
|
||||
///
|
||||
/// So, for example, when moving a region of cells [a, b] (inclusive), call this
|
||||
/// function with `x = a` and `x = b + 1`. It is okay if `x` is out of bounds by
|
||||
/// 1, this will be interpreted correctly.
|
||||
///
|
||||
/// DOES NOT MODIFY ROW WRAP STATE! See `cursorResetWrap` for that.
|
||||
///
|
||||
/// The following boundary conditions are handled:
|
||||
///
|
||||
/// - `x - 1` is a wide character and `x` is a spacer tail:
|
||||
/// o Both cells will be cleared.
|
||||
/// o If `x - 1` is the start of the row and was wrapped from a previous row
|
||||
/// then the previous row is checked for a spacer head, which is cleared if
|
||||
/// present.
|
||||
///
|
||||
/// - `x == 0` and is a wide character:
|
||||
/// o If the row is a wrap continuation then the previous row will be checked
|
||||
/// for a spacer head, which is cleared if present.
|
||||
///
|
||||
/// - `x == cols` and `x - 1` is a spacer head:
|
||||
/// o `x - 1` will be cleared.
|
||||
///
|
||||
/// NOTE(qwerasd): This method is not scrolling region aware, and cannot be
|
||||
/// since it's on Screen not Terminal. This needs to be addressed down the
|
||||
/// line. Not an extremely urgent issue since it's an edge case of an edge
|
||||
/// case, but not ideal.
|
||||
pub fn splitCellBoundary(
|
||||
self: *Screen,
|
||||
x: size.CellCountInt,
|
||||
) void {
|
||||
const page = &self.cursor.page_pin.page.data;
|
||||
|
||||
page.pauseIntegrityChecks(true);
|
||||
defer page.pauseIntegrityChecks(false);
|
||||
|
||||
const cols = self.cursor.page_pin.page.data.size.cols;
|
||||
|
||||
// `x` may be up to an INCLUDING `cols`, since that signifies splitting
|
||||
// the boundary to the right of the final cell in the row.
|
||||
assert(x <= cols);
|
||||
|
||||
// [ A B C D E F|]
|
||||
// ^ Boundary between final cell and row end.
|
||||
if (x == cols) {
|
||||
if (!self.cursor.page_row.wrap) return;
|
||||
|
||||
const cells = self.cursor.page_pin.cells(.all);
|
||||
|
||||
// Spacer head at end of wrapped row.
|
||||
if (cells[cols - 1].wide == .spacer_head) {
|
||||
self.clearCells(
|
||||
page,
|
||||
self.cursor.page_row,
|
||||
cells[cols - 1 ..][0..1],
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// [|A B C D E F ]
|
||||
// ^ Boundary between first cell and row start.
|
||||
//
|
||||
// OR
|
||||
//
|
||||
// [ A|B C D E F ]
|
||||
// ^ Boundary between first cell and second cell.
|
||||
//
|
||||
// First cell may be a wrapped wide cell with a spacer
|
||||
// head on the previous row that needs to be cleared.
|
||||
if ((x == 0 or x == 1) and self.cursor.page_row.wrap_continuation) {
|
||||
const cells = self.cursor.page_pin.cells(.all);
|
||||
|
||||
// If the first cell in a row is wide the previous row
|
||||
// may have a spacer head which needs to be cleared.
|
||||
if (cells[0].wide == .wide) {
|
||||
if (self.cursor.page_pin.up(1)) |p_row| {
|
||||
const p_rac = p_row.rowAndCell();
|
||||
const p_cells = p_row.cells(.all);
|
||||
const p_cell = p_cells[p_row.page.data.size.cols - 1];
|
||||
if (p_cell.wide == .spacer_head) {
|
||||
self.clearCells(
|
||||
&p_row.page.data,
|
||||
p_rac.row,
|
||||
p_cells[p_row.page.data.size.cols - 1 ..][0..1],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If x is 0 then we're done.
|
||||
if (x == 0) return;
|
||||
|
||||
// [ ... X|Y ... ]
|
||||
// ^ Boundary between two cells in the middle of the row.
|
||||
{
|
||||
assert(x > 0);
|
||||
assert(x < cols);
|
||||
|
||||
const cells = self.cursor.page_pin.cells(.all);
|
||||
|
||||
const left = cells[x - 1];
|
||||
switch (left.wide) {
|
||||
// There should not be spacer heads in the middle of the row.
|
||||
.spacer_head => unreachable,
|
||||
|
||||
// We don't need to do anything for narrow cells or spacer tails.
|
||||
.narrow, .spacer_tail => {},
|
||||
|
||||
// A wide char would be split, so must be cleared.
|
||||
.wide => {
|
||||
self.clearCells(
|
||||
page,
|
||||
self.cursor.page_row,
|
||||
cells[x - 1 ..][0..2],
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the blank cell to use when doing terminal operations that
|
||||
/// require preserving the bg color.
|
||||
pub fn blankCell(self: *const Screen) Cell {
|
||||
|
@ -1907,29 +1907,20 @@ 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;
|
||||
|
||||
// left is just the cursor position but as a multi-pointer
|
||||
const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell);
|
||||
var page = &self.screen.cursor.page_pin.page.data;
|
||||
|
||||
// If our X is a wide spacer tail then we need to erase the
|
||||
// previous cell too so we don't split a multi-cell character.
|
||||
if (self.screen.cursor.page_cell.wide == .spacer_tail) {
|
||||
assert(self.screen.cursor.x > 0);
|
||||
self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]);
|
||||
}
|
||||
|
||||
// Remaining cols from our cursor to the right margin.
|
||||
const rem = self.scrolling_region.right - self.screen.cursor.x + 1;
|
||||
|
||||
// We can only insert blanks up to our remaining cols
|
||||
const count = @min(count_req, rem);
|
||||
|
||||
self.screen.splitCellBoundary(self.screen.cursor.x);
|
||||
self.screen.splitCellBoundary(self.screen.cursor.x + count);
|
||||
self.screen.splitCellBoundary(self.scrolling_region.right + 1);
|
||||
|
||||
// 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.
|
||||
@ -1941,35 +1932,6 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void {
|
||||
|
||||
const right: [*]Cell = left + (scroll_amount - 1);
|
||||
|
||||
const end: *Cell = @ptrCast(right + count);
|
||||
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
|
||||
// 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);
|
||||
@ -1980,6 +1942,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void {
|
||||
// Insert blanks. The blanks preserve the background color.
|
||||
self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]);
|
||||
|
||||
// Our row's soft-wrap is always reset.
|
||||
self.screen.cursorResetWrap();
|
||||
|
||||
// Our row is always dirty
|
||||
self.screen.cursorMarkDirty();
|
||||
}
|
||||
@ -1987,12 +1952,6 @@ pub fn deleteChars(self: *Terminal, count_req: 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: {
|
||||
@ -2009,6 +1968,17 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
|
||||
break :end end;
|
||||
};
|
||||
|
||||
// Handle any boundary conditions on the edges of the erased area.
|
||||
//
|
||||
// TODO(qwerasd): This isn't actually correct if you take in to account
|
||||
// protected modes. We need to figure out how to make `clearCells` or at
|
||||
// least `clearUnprotectedCells` handle boundary conditions...
|
||||
self.screen.splitCellBoundary(self.screen.cursor.x);
|
||||
self.screen.splitCellBoundary(end);
|
||||
|
||||
// Reset our row's soft-wrap.
|
||||
self.screen.cursorResetWrap();
|
||||
|
||||
// Mark our cursor row as dirty
|
||||
self.screen.cursorMarkDirty();
|
||||
|
||||
@ -2051,8 +2021,8 @@ pub fn eraseLine(
|
||||
x -= 1;
|
||||
}
|
||||
|
||||
// This resets the soft-wrap of this line
|
||||
self.screen.cursor.page_row.wrap = false;
|
||||
// Reset our row's soft-wrap.
|
||||
self.screen.cursorResetWrap();
|
||||
|
||||
break :right .{ x, self.cols };
|
||||
},
|
||||
@ -6063,6 +6033,60 @@ test "Terminal: eraseChars protected attributes ignored with dec set" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: eraseChars wide char boundary conditions" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 1, .cols = 8 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printString("😀a😀b😀");
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("😀a😀b😀", str);
|
||||
}
|
||||
|
||||
t.setCursorPos(1, 2);
|
||||
t.eraseChars(3);
|
||||
t.screen.cursor.page_pin.page.data.assertIntegrity();
|
||||
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(" b😀", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: eraseChars wide char wrap boundary conditions" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 3, .cols = 8 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
try t.printString(".......😀abcde😀......");
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(".......\n😀abcde\n😀......", str);
|
||||
|
||||
const unwrapped = try t.plainStringUnwrapped(alloc);
|
||||
defer testing.allocator.free(unwrapped);
|
||||
try testing.expectEqualStrings(".......😀abcde😀......", unwrapped);
|
||||
}
|
||||
|
||||
t.setCursorPos(2, 2);
|
||||
t.eraseChars(3);
|
||||
t.screen.cursor.page_pin.page.data.assertIntegrity();
|
||||
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(".......\n cde\n😀......", str);
|
||||
|
||||
const unwrapped = try t.plainStringUnwrapped(alloc);
|
||||
defer testing.allocator.free(unwrapped);
|
||||
try testing.expectEqualStrings("....... cde\n😀......", unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: reverseIndex" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 2, .rows = 5 });
|
||||
@ -8909,6 +8933,169 @@ test "Terminal: deleteChars split wide character tail" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteChars wide char boundary conditions" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 1, .cols = 8 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// EXPLANATION(qwerasd):
|
||||
//
|
||||
// There are 3 or 4 boundaries to be concerned with in deleteChars,
|
||||
// depending on how you count them. Consider the following terminal:
|
||||
//
|
||||
// +--------+
|
||||
// 0 |.ABCDEF.|
|
||||
// : ^ : (^ = cursor)
|
||||
// +--------+
|
||||
//
|
||||
// if we DCH 3 we get
|
||||
//
|
||||
// +--------+
|
||||
// 0 |.DEF....|
|
||||
// +--------+
|
||||
//
|
||||
// The boundaries exist at the following points then:
|
||||
//
|
||||
// +--------+
|
||||
// 0 |.ABCDEF.|
|
||||
// :11 22 33:
|
||||
// +--------+
|
||||
//
|
||||
// I'm counting 2 for double since it's both the end of the deleted
|
||||
// content and the start of the content that is shifted in to place.
|
||||
//
|
||||
// Now consider wide characters (represented as `WW`) at these boundaries:
|
||||
//
|
||||
// +--------+
|
||||
// 0 |WWaWWbWW|
|
||||
// : ^ : (^ = cursor)
|
||||
// : ^^^ : (^ = deleted by DCH 3)
|
||||
// +--------+
|
||||
//
|
||||
// -> DCH 3
|
||||
// -> The first 2 wide characters are split & destroyed (verified in xterm)
|
||||
//
|
||||
// +--------+
|
||||
// 0 |..bWW...|
|
||||
// +--------+
|
||||
|
||||
try t.printString("😀a😀b😀");
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("😀a😀b😀", str);
|
||||
}
|
||||
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteChars(3);
|
||||
t.screen.cursor.page_pin.page.data.assertIntegrity();
|
||||
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(" b😀", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteChars wide char wrap boundary conditions" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 3, .cols = 8 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// EXPLANATION(qwerasd):
|
||||
// (cont. from "Terminal: deleteChars wide char boundary conditions")
|
||||
//
|
||||
// Additionally consider soft-wrapped wide chars (`H` = spacer head):
|
||||
//
|
||||
// +--------+
|
||||
// 0 |.......H…
|
||||
// 1 …WWabcdeH…
|
||||
// : ^ : (^ = cursor)
|
||||
// : ^^^ : (^ = deleted by DCH 3)
|
||||
// 2 …WW......|
|
||||
// +--------+
|
||||
//
|
||||
// -> DCH 3
|
||||
// -> First wide character split and destroyed, including spacer head,
|
||||
// second spacer head removed (verified in xterm).
|
||||
// -> Wrap state of row reset
|
||||
//
|
||||
// +--------+
|
||||
// 0 |........|
|
||||
// 1 |.cde....|
|
||||
// 2 |WW......|
|
||||
// +--------+
|
||||
//
|
||||
|
||||
try t.printString(".......😀abcde😀......");
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(".......\n😀abcde\n😀......", str);
|
||||
|
||||
const unwrapped = try t.plainStringUnwrapped(alloc);
|
||||
defer testing.allocator.free(unwrapped);
|
||||
try testing.expectEqualStrings(".......😀abcde😀......", unwrapped);
|
||||
}
|
||||
|
||||
t.setCursorPos(2, 2);
|
||||
t.deleteChars(3);
|
||||
t.screen.cursor.page_pin.page.data.assertIntegrity();
|
||||
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings(".......\n cde\n😀......", str);
|
||||
|
||||
const unwrapped = try t.plainStringUnwrapped(alloc);
|
||||
defer testing.allocator.free(unwrapped);
|
||||
try testing.expectEqualStrings("....... cde\n😀......", unwrapped);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteChars wide char across right margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .rows = 3, .cols = 8 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// scroll region
|
||||
// VVVVVV
|
||||
// +-######-+
|
||||
// |.abcdeWW|
|
||||
// : ^ : (^ = cursor)
|
||||
// +--------+
|
||||
//
|
||||
// DCH 1
|
||||
|
||||
try t.printString("123456橋");
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
t.setLeftAndRightMargin(2, 7);
|
||||
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("123456橋", str);
|
||||
}
|
||||
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteChars(1);
|
||||
t.screen.cursor.page_pin.page.data.assertIntegrity();
|
||||
|
||||
// NOTE: This behavior is slightly inconsistent with xterm. xterm
|
||||
// _visually_ splits the wide character (half the wide character shows
|
||||
// up in col 6 and half in col 8). In all other wide char split scenarios,
|
||||
// xterm clears the cell. Therefore, we've chosen to clear the cell here.
|
||||
// Given we have space, we also could actually preserve it, but I haven't
|
||||
// yet found a terminal that behaves that way. We should be open to
|
||||
// revisiting this behavior but for now we're going with the simpler
|
||||
// impl.
|
||||
{
|
||||
const str = try t.plainString(alloc);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("13456", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: saveCursor" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 3, .rows = 3 });
|
||||
|
Reference in New Issue
Block a user