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
/// exist or is out of bounds.
///
/// Warning: this is slow and should not be used in performance critical paths
pub fn getCell(self: *const PageList, pt: point.Point) ?Cell {
const row = self.getTopLeft(pt).forward(pt.y) orelse return null;
const rac = row.page.data.getRowAndCell(row.row_offset, pt.x);
const row = self.getTopLeft(pt).forward(pt.coord().y) orelse return null;
const rac = row.page.data.getRowAndCell(pt.coord().x, row.row_offset);
return .{
.page = row.page,
.row = rac.row,
.cell = rac.cell,
.row_idx = row.row_offset,
.col_idx = pt.x,
.col_idx = pt.coord().x,
};
}
@ -282,6 +284,14 @@ pub const RowOffset = struct {
};
}
/// TODO: docs
pub fn backward(self: RowOffset, idx: usize) ?RowOffset {
return switch (self.backwardOverflow(idx)) {
.offset => |v| v,
.overflow => null,
};
}
/// Move the offset forward n rows. If the offset goes beyond the
/// end of the screen, return the overflow amount.
fn forwardOverflow(self: RowOffset, n: usize) union(enum) {
@ -313,6 +323,37 @@ pub const RowOffset = struct {
n_left -= page.data.size.rows;
}
}
/// Move the offset backward n rows. If the offset goes beyond the
/// start of the screen, return the overflow amount.
fn backwardOverflow(self: RowOffset, n: usize) union(enum) {
offset: RowOffset,
overflow: struct {
end: RowOffset,
remaining: usize,
},
} {
// Index fits within this page
if (n >= self.row_offset) return .{ .offset = .{
.page = self.page,
.row_offset = self.row_offset - n,
} };
// Need to traverse page links to find the page
var page: *List.Node = self.page;
var n_left: usize = n - self.row_offset;
while (true) {
page = page.prev orelse return .{ .overflow = .{
.end = .{ .page = page, .row_offset = 0 },
.remaining = n_left,
} };
if (n_left <= page.data.size.rows) return .{ .offset = .{
.page = page,
.row_offset = page.data.size.rows - n_left,
} };
n_left -= page.data.size.rows;
}
}
};
const Cell = struct {

View File

@ -81,14 +81,57 @@ pub fn deinit(self: *Screen) void {
self.pages.deinit();
}
pub fn cursorCellRight(self: *Screen) *pagepkg.Cell {
assert(self.cursor.x + 1 < self.pages.cols);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell + 1);
}
pub fn cursorCellLeft(self: *Screen) *pagepkg.Cell {
assert(self.cursor.x > 0);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell - 1);
}
pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell {
assert(self.cursor.y > 0);
const page_offset = self.cursor.page_offset.backward(1).?;
const page_rac = page_offset.rowAndCell(self.pages.cols - 1);
return page_rac.cell;
}
/// Move the cursor right. This is a specialized function that is very fast
/// if the caller can guarantee we have space to move right (no wrapping).
pub fn cursorRight(self: *Screen) void {
assert(self.cursor.x + 1 < self.pages.cols);
pub fn cursorRight(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.x + n < self.pages.cols);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
self.cursor.page_cell = @ptrCast(cell + 1);
self.cursor.x += 1;
self.cursor.page_cell = @ptrCast(cell + n);
self.cursor.x += n;
}
/// Move the cursor left.
pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.x >= n);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
self.cursor.page_cell = @ptrCast(cell - n);
self.cursor.x -= n;
}
/// Move the cursor up.
///
/// Precondition: The cursor is not at the top of the screen.
pub fn cursorUp(self: *Screen) void {
assert(self.cursor.y > 0);
const page_offset = self.cursor.page_offset.backward(1).?;
const page_rac = page_offset.rowAndCell(self.cursor.x);
self.cursor.page_offset = page_offset;
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
self.cursor.y -= 1;
}
/// Move the cursor down.
@ -182,9 +225,26 @@ pub fn dumpString(
// TODO: handle wrap
blank_rows += 1;
var blank_cells: usize = 0;
for (cells) |cell| {
// TODO: handle blanks between chars
if (cell.codepoint == 0) break;
// Skip spacers
switch (cell.wide) {
.narrow, .wide => {},
.spacer_head, .spacer_tail => continue,
}
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
if (cell.codepoint == 0) {
blank_cells += 1;
continue;
}
if (blank_cells > 0) {
for (0..blank_cells) |_| try writer.writeByte(' ');
blank_cells = 0;
}
try writer.print("{u}", .{cell.codepoint});
}
}

View File

@ -265,13 +265,34 @@ pub fn print(self: *Terminal, c: u21) !void {
switch (width) {
// Single cell is very easy: just write in the cell
1 => @call(.always_inline, printCell, .{ self, c }),
1 => @call(.always_inline, printCell, .{ self, c, .narrow }),
// Wide character requires a spacer. We print this by
// using two cells: the first is flagged "wide" and has the
// wide char. The second is guaranteed to be a spacer if
// we're not at the end of the line.
2 => @panic("TODO: wide characters"),
2 => if ((right_limit - self.scrolling_region.left) > 1) {
// If we don't have space for the wide char, we need
// to insert spacers and wrap. Then we just print the wide
// char as normal.
if (self.screen.cursor.x == right_limit - 1) {
// If we don't have wraparound enabled then we don't print
// this character at all and don't move the cursor. This is
// how xterm behaves.
if (!self.modes.get(.wraparound)) return;
self.printCell(' ', .spacer_head);
try self.printWrap();
}
self.printCell(c, .wide);
self.screen.cursorRight(1);
self.printCell(' ', .spacer_tail);
} else {
// This is pretty broken, terminals should never be only 1-wide.
// We sould prevent this downstream.
self.printCell(' ', .narrow);
},
else => unreachable,
}
@ -284,47 +305,67 @@ pub fn print(self: *Terminal, c: u21) !void {
}
// Move the cursor
self.screen.cursorRight();
self.screen.cursorRight(1);
}
fn printCell(self: *Terminal, unmapped_c: u21) void {
fn printCell(
self: *Terminal,
unmapped_c: u21,
wide: Cell.Wide,
) void {
// TODO: charsets
const c: u21 = unmapped_c;
// If this cell is wide char then we need to clear it.
// We ignore wide spacer HEADS because we can just write
// single-width characters into that.
// if (cell.attrs.wide) {
// const x = self.screen.cursor.x + 1;
// if (x < self.cols) {
// const spacer_cell = row.getCellPtr(x);
// spacer_cell.* = self.screen.cursor.pen;
// }
//
// if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) {
// self.clearWideSpacerHead();
// }
// } else if (cell.attrs.wide_spacer_tail) {
// assert(self.screen.cursor.x > 0);
// const x = self.screen.cursor.x - 1;
//
// const wide_cell = row.getCellPtr(x);
// wide_cell.* = self.screen.cursor.pen;
//
// if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) {
// self.clearWideSpacerHead();
// }
// }
// TODO: prev cell overwriting style, dec refs, etc.
const cell = self.screen.cursor.page_cell;
// If the wide property of this cell is the same, then we don't
// need to do the special handling here because the structure will
// be the same. If it is NOT the same, then we may need to clear some
// cells.
if (cell.wide != wide) {
switch (cell.wide) {
// Previous cell was narrow. Do nothing.
.narrow => {},
// Previous cell was wide. We need to clear the tail and head.
.wide => wide: {
if (self.screen.cursor.x >= self.cols - 1) break :wide;
const spacer_cell = self.screen.cursorCellRight();
spacer_cell.* = .{ .style_id = self.screen.cursor.style_id };
if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) {
const head_cell = self.screen.cursorCellEndOfPrev();
head_cell.wide = .narrow;
}
},
.spacer_tail => {
assert(self.screen.cursor.x > 0);
const wide_cell = self.screen.cursorCellLeft();
wide_cell.* = .{ .style_id = self.screen.cursor.style_id };
if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) {
const head_cell = self.screen.cursorCellEndOfPrev();
head_cell.wide = .narrow;
}
},
// TODO: this case was not handled in the old terminal implementation
// but it feels like we should do something. investigate other
// terminals (xterm mainly) and see whats up.
.spacer_head => {},
}
}
// If the prior value had graphemes, clear those
//if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x);
// TODO: prev cell overwriting style
if (cell.grapheme) @panic("TODO: clear graphemes");
// Write
self.screen.cursor.page_cell.* = .{
.style_id = self.screen.cursor.style_id,
.codepoint = c,
.wide = wide,
};
// If we have non-default style then we need to update the ref count.
@ -411,6 +452,60 @@ pub fn index(self: *Terminal) !void {
}
}
// Set Cursor Position. Move cursor to the position indicated
// by row and column (1-indexed). If column is 0, it is adjusted to 1.
// If column is greater than the right-most column it is adjusted to
// the right-most column. If row is 0, it is adjusted to 1. If row is
// greater than the bottom-most row it is adjusted to the bottom-most
// row.
pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void {
// If cursor origin mode is set the cursor row will be moved relative to
// the top margin row and adjusted to be above or at bottom-most row in
// the current scroll region.
//
// If origin mode is set and left and right margin mode is set the cursor
// will be moved relative to the left margin column and adjusted to be on
// or left of the right margin column.
const params: struct {
x_offset: size.CellCountInt = 0,
y_offset: size.CellCountInt = 0,
x_max: size.CellCountInt,
y_max: size.CellCountInt,
} = if (self.modes.get(.origin)) .{
.x_offset = self.scrolling_region.left,
.y_offset = self.scrolling_region.top,
.x_max = self.scrolling_region.right + 1, // We need this 1-indexed
.y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed
} else .{
.x_max = self.cols,
.y_max = self.rows,
};
// Unset pending wrap state
self.screen.cursor.pending_wrap = false;
// Calculate our new x/y
const row = if (row_req == 0) 1 else row_req;
const col = if (col_req == 0) 1 else col_req;
const x = @min(params.x_max, col + params.x_offset) -| 1;
const y = @min(params.y_max, row + params.y_offset) -| 1;
// If the y is unchanged then this is fast pointer math
if (y == self.screen.cursor.y) {
if (x > self.screen.cursor.x) {
self.screen.cursorRight(x - self.screen.cursor.x);
} else {
self.screen.cursorLeft(self.screen.cursor.x - x);
}
return;
}
@panic("TODO: y change");
// log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y });
}
/// Return the current string value of the terminal. Newlines are
/// encoded as "\n". This omits any formatting such as fg/bg.
///
@ -489,3 +584,77 @@ test "Terminal: print single very long line" {
// that we simply do not crash.
for (0..1000) |_| try t.print('x');
}
test "Terminal: print wide char" {
var t = try init(testing.allocator, 80, 80);
defer t.deinit(testing.allocator);
try t.print(0x1F600); // Smiley face
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x1F600), cell.codepoint);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Terminal: print over wide char at 0,0" {
var t = try init(testing.allocator, 80, 80);
defer t.deinit(testing.allocator);
try t.print(0x1F600); // Smiley face
t.setCursorPos(0, 0);
try t.print('A'); // Smiley face
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
try testing.expectEqual(@as(usize, 1), t.screen.cursor.x);
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'A'), cell.codepoint);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.codepoint);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
}
test "Terminal: print over wide spacer tail" {
var t = try init(testing.allocator, 5, 5);
defer t.deinit(testing.allocator);
try t.print('橋');
t.setCursorPos(1, 2);
try t.print('X');
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.codepoint);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
{
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'X'), cell.codepoint);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
{
const str = try t.plainString(testing.allocator);
defer testing.allocator.free(str);
try testing.expectEqualStrings(" X", str);
}
}

View File

@ -369,7 +369,28 @@ pub const Cell = packed struct(u64) {
/// map for this cell to build a multi-codepoint grapheme.
grapheme: bool = false,
_padding: u26 = 0,
/// The wide property of this cell, for wide characters. Characters in
/// a terminal grid can only be 1 or 2 cells wide. A wide character
/// is always next to a spacer. This is used to determine both the width
/// and spacer properties of a cell.
wide: Wide = .narrow,
_padding: u24 = 0,
pub const Wide = enum(u2) {
/// Not a wide character, cell width 1.
narrow = 0,
/// Wide character, cell width 2.
wide = 1,
/// Spacer after wide character. Do not render.
spacer_tail = 2,
/// Spacer at the end of a soft-wrapped line to indicate that a wide
/// character is continued on the next line.
spacer_head = 3,
};
/// Returns true if the set of cells has text in it.
pub fn hasText(cells: []const Cell) bool {