terminal/new: wide char support

This commit is contained in:
Mitchell Hashimoto
2024-02-23 16:33:16 -08:00
parent 21c6026922
commit 587289662f
4 changed files with 332 additions and 41 deletions

View File

@ -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 /// Get the cell at the given point, or null if the cell does not
/// exist or is out of bounds. /// 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 { pub fn getCell(self: *const PageList, pt: point.Point) ?Cell {
const row = self.getTopLeft(pt).forward(pt.y) orelse return null; const row = self.getTopLeft(pt).forward(pt.coord().y) orelse return null;
const rac = row.page.data.getRowAndCell(row.row_offset, pt.x); const rac = row.page.data.getRowAndCell(pt.coord().x, row.row_offset);
return .{ return .{
.page = row.page, .page = row.page,
.row = rac.row, .row = rac.row,
.cell = rac.cell, .cell = rac.cell,
.row_idx = row.row_offset, .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 /// Move the offset forward n rows. If the offset goes beyond the
/// end of the screen, return the overflow amount. /// end of the screen, return the overflow amount.
fn forwardOverflow(self: RowOffset, n: usize) union(enum) { fn forwardOverflow(self: RowOffset, n: usize) union(enum) {
@ -313,6 +323,37 @@ pub const RowOffset = struct {
n_left -= page.data.size.rows; 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 { const Cell = struct {

View File

@ -81,14 +81,57 @@ pub fn deinit(self: *Screen) void {
self.pages.deinit(); 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 /// 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). /// if the caller can guarantee we have space to move right (no wrapping).
pub fn cursorRight(self: *Screen) void { pub fn cursorRight(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.x + 1 < self.pages.cols); assert(self.cursor.x + n < self.pages.cols);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
self.cursor.page_cell = @ptrCast(cell + 1); self.cursor.page_cell = @ptrCast(cell + n);
self.cursor.x += 1; 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.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. /// Move the cursor down.
@ -182,9 +225,26 @@ pub fn dumpString(
// TODO: handle wrap // TODO: handle wrap
blank_rows += 1; blank_rows += 1;
var blank_cells: usize = 0;
for (cells) |cell| { for (cells) |cell| {
// TODO: handle blanks between chars // Skip spacers
if (cell.codepoint == 0) break; 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}); try writer.print("{u}", .{cell.codepoint});
} }
} }

View File

@ -265,13 +265,34 @@ pub fn print(self: *Terminal, c: u21) !void {
switch (width) { switch (width) {
// Single cell is very easy: just write in the cell // 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 // Wide character requires a spacer. We print this by
// using two cells: the first is flagged "wide" and has the // using two cells: the first is flagged "wide" and has the
// wide char. The second is guaranteed to be a spacer if // wide char. The second is guaranteed to be a spacer if
// we're not at the end of the line. // 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, else => unreachable,
} }
@ -284,47 +305,67 @@ pub fn print(self: *Terminal, c: u21) !void {
} }
// Move the cursor // 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 // TODO: charsets
const c: u21 = unmapped_c; const c: u21 = unmapped_c;
// If this cell is wide char then we need to clear it. // TODO: prev cell overwriting style, dec refs, etc.
// We ignore wide spacer HEADS because we can just write const cell = self.screen.cursor.page_cell;
// single-width characters into that.
// if (cell.attrs.wide) { // If the wide property of this cell is the same, then we don't
// const x = self.screen.cursor.x + 1; // need to do the special handling here because the structure will
// if (x < self.cols) { // be the same. If it is NOT the same, then we may need to clear some
// const spacer_cell = row.getCellPtr(x); // cells.
// spacer_cell.* = self.screen.cursor.pen; if (cell.wide != wide) {
// } switch (cell.wide) {
// // Previous cell was narrow. Do nothing.
// if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { .narrow => {},
// self.clearWideSpacerHead();
// } // Previous cell was wide. We need to clear the tail and head.
// } else if (cell.attrs.wide_spacer_tail) { .wide => wide: {
// assert(self.screen.cursor.x > 0); if (self.screen.cursor.x >= self.cols - 1) break :wide;
// const x = self.screen.cursor.x - 1;
// const spacer_cell = self.screen.cursorCellRight();
// const wide_cell = row.getCellPtr(x); spacer_cell.* = .{ .style_id = self.screen.cursor.style_id };
// wide_cell.* = self.screen.cursor.pen; if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) {
// const head_cell = self.screen.cursorCellEndOfPrev();
// if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { head_cell.wide = .narrow;
// self.clearWideSpacerHead(); }
// } },
// }
.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 the prior value had graphemes, clear those
//if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); if (cell.grapheme) @panic("TODO: clear graphemes");
// TODO: prev cell overwriting style
// Write // Write
self.screen.cursor.page_cell.* = .{ self.screen.cursor.page_cell.* = .{
.style_id = self.screen.cursor.style_id, .style_id = self.screen.cursor.style_id,
.codepoint = c, .codepoint = c,
.wide = wide,
}; };
// If we have non-default style then we need to update the ref count. // 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 /// Return the current string value of the terminal. Newlines are
/// encoded as "\n". This omits any formatting such as fg/bg. /// 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. // that we simply do not crash.
for (0..1000) |_| try t.print('x'); for (0..1000) |_| try t.print('x');
} }
test "Terminal: print wide char" {
var t = try init(testing.allocator, 80, 80);
defer t.deinit(testing.allocator);
try t.print(0x1F600); // Smiley face
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x1F600), cell.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);
}
}

View File

@ -369,7 +369,28 @@ pub const Cell = packed struct(u64) {
/// map for this cell to build a multi-codepoint grapheme. /// map for this cell to build a multi-codepoint grapheme.
grapheme: bool = false, 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. /// Returns true if the set of cells has text in it.
pub fn hasText(cells: []const Cell) bool { pub fn hasText(cells: []const Cell) bool {