mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-17 01:06:08 +03:00
terminal/new: wide char support
This commit is contained in:
@ -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 {
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user