mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 00:36:07 +03:00
1057 lines
36 KiB
Zig
1057 lines
36 KiB
Zig
//! The primary terminal emulation structure. This represents a single
|
|
//! "terminal" containing a grid of characters and exposes various operations
|
|
//! on that grid. This also maintains the scrollback buffer.
|
|
const Terminal = @This();
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = std.debug.assert;
|
|
const testing = std.testing;
|
|
const Allocator = std.mem.Allocator;
|
|
const unicode = @import("../../unicode/main.zig");
|
|
|
|
const ansi = @import("../ansi.zig");
|
|
const modes = @import("../modes.zig");
|
|
const charsets = @import("../charsets.zig");
|
|
const csi = @import("../csi.zig");
|
|
const kitty = @import("../kitty.zig");
|
|
const sgr = @import("../sgr.zig");
|
|
const Tabstops = @import("../Tabstops.zig");
|
|
const color = @import("../color.zig");
|
|
const mouse_shape = @import("../mouse_shape.zig");
|
|
|
|
const size = @import("size.zig");
|
|
const pagepkg = @import("page.zig");
|
|
const style = @import("style.zig");
|
|
const Screen = @import("Screen.zig");
|
|
const Cell = pagepkg.Cell;
|
|
const Row = pagepkg.Row;
|
|
|
|
const log = std.log.scoped(.terminal);
|
|
|
|
/// Default tabstop interval
|
|
const TABSTOP_INTERVAL = 8;
|
|
|
|
/// Screen type is an enum that tracks whether a screen is primary or alternate.
|
|
pub const ScreenType = enum {
|
|
primary,
|
|
alternate,
|
|
};
|
|
|
|
/// The semantic prompt type. This is used when tracking a line type and
|
|
/// requires integration with the shell. By default, we mark a line as "none"
|
|
/// meaning we don't know what type it is.
|
|
///
|
|
/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
|
|
pub const SemanticPrompt = enum {
|
|
prompt,
|
|
prompt_continuation,
|
|
input,
|
|
command,
|
|
};
|
|
|
|
/// Screen is the current screen state. The "active_screen" field says what
|
|
/// the current screen is. The backup screen is the opposite of the active
|
|
/// screen.
|
|
active_screen: ScreenType,
|
|
screen: Screen,
|
|
secondary_screen: Screen,
|
|
|
|
/// Whether we're currently writing to the status line (DECSASD and DECSSDT).
|
|
/// We don't support a status line currently so we just black hole this
|
|
/// data so that it doesn't mess up our main display.
|
|
status_display: ansi.StatusDisplay = .main,
|
|
|
|
/// Where the tabstops are.
|
|
tabstops: Tabstops,
|
|
|
|
/// The size of the terminal.
|
|
rows: size.CellCountInt,
|
|
cols: size.CellCountInt,
|
|
|
|
/// The size of the screen in pixels. This is used for pty events and images
|
|
width_px: u32 = 0,
|
|
height_px: u32 = 0,
|
|
|
|
/// The current scrolling region.
|
|
scrolling_region: ScrollingRegion,
|
|
|
|
/// The last reported pwd, if any.
|
|
pwd: std.ArrayList(u8),
|
|
|
|
/// The default color palette. This is only modified by changing the config file
|
|
/// and is used to reset the palette when receiving an OSC 104 command.
|
|
default_palette: color.Palette = color.default,
|
|
|
|
/// The color palette to use. The mask indicates which palette indices have been
|
|
/// modified with OSC 4
|
|
color_palette: struct {
|
|
const Mask = std.StaticBitSet(@typeInfo(color.Palette).Array.len);
|
|
colors: color.Palette = color.default,
|
|
mask: Mask = Mask.initEmpty(),
|
|
} = .{},
|
|
|
|
/// The previous printed character. This is used for the repeat previous
|
|
/// char CSI (ESC [ <n> b).
|
|
previous_char: ?u21 = null,
|
|
|
|
/// The modes that this terminal currently has active.
|
|
modes: modes.ModeState = .{},
|
|
|
|
/// The most recently set mouse shape for the terminal.
|
|
mouse_shape: mouse_shape.MouseShape = .text,
|
|
|
|
/// These are just a packed set of flags we may set on the terminal.
|
|
flags: packed struct {
|
|
// This isn't a mode, this is set by OSC 133 using the "A" event.
|
|
// If this is true, it tells us that the shell supports redrawing
|
|
// the prompt and that when we resize, if the cursor is at a prompt,
|
|
// then we should clear the screen below and allow the shell to redraw.
|
|
shell_redraws_prompt: bool = false,
|
|
|
|
// This is set via ESC[4;2m. Any other modify key mode just sets
|
|
// this to false and we act in mode 1 by default.
|
|
modify_other_keys_2: bool = false,
|
|
|
|
/// The mouse event mode and format. These are set to the last
|
|
/// set mode in modes. You can't get the right event/format to use
|
|
/// based on modes alone because modes don't show you what order
|
|
/// this was called so we have to track it separately.
|
|
mouse_event: MouseEvents = .none,
|
|
mouse_format: MouseFormat = .x10,
|
|
|
|
/// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1)
|
|
/// then we want to capture the shift key for the mouse protocol
|
|
/// if the configuration allows it.
|
|
mouse_shift_capture: enum { null, false, true } = .null,
|
|
} = .{},
|
|
|
|
/// The event types that can be reported for mouse-related activities.
|
|
/// These are all mutually exclusive (hence in a single enum).
|
|
pub const MouseEvents = enum(u3) {
|
|
none = 0,
|
|
x10 = 1, // 9
|
|
normal = 2, // 1000
|
|
button = 3, // 1002
|
|
any = 4, // 1003
|
|
|
|
/// Returns true if this event sends motion events.
|
|
pub fn motion(self: MouseEvents) bool {
|
|
return self == .button or self == .any;
|
|
}
|
|
};
|
|
|
|
/// The format of mouse events when enabled.
|
|
/// These are all mutually exclusive (hence in a single enum).
|
|
pub const MouseFormat = enum(u3) {
|
|
x10 = 0,
|
|
utf8 = 1, // 1005
|
|
sgr = 2, // 1006
|
|
urxvt = 3, // 1015
|
|
sgr_pixels = 4, // 1016
|
|
};
|
|
|
|
/// Scrolling region is the area of the screen designated where scrolling
|
|
/// occurs. When scrolling the screen, only this viewport is scrolled.
|
|
pub const ScrollingRegion = struct {
|
|
// Top and bottom of the scroll region (0-indexed)
|
|
// Precondition: top < bottom
|
|
top: size.CellCountInt,
|
|
bottom: size.CellCountInt,
|
|
|
|
// Left/right scroll regions.
|
|
// Precondition: right > left
|
|
// Precondition: right <= cols - 1
|
|
left: size.CellCountInt,
|
|
right: size.CellCountInt,
|
|
};
|
|
|
|
/// Initialize a new terminal.
|
|
pub fn init(alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt) !Terminal {
|
|
return Terminal{
|
|
.cols = cols,
|
|
.rows = rows,
|
|
.active_screen = .primary,
|
|
// TODO: configurable scrollback
|
|
.screen = try Screen.init(alloc, cols, rows, 10000),
|
|
// No scrollback for the alternate screen
|
|
.secondary_screen = try Screen.init(alloc, cols, rows, 0),
|
|
.tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL),
|
|
.scrolling_region = .{
|
|
.top = 0,
|
|
.bottom = rows - 1,
|
|
.left = 0,
|
|
.right = cols - 1,
|
|
},
|
|
.pwd = std.ArrayList(u8).init(alloc),
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Terminal, alloc: Allocator) void {
|
|
self.tabstops.deinit(alloc);
|
|
self.screen.deinit();
|
|
self.secondary_screen.deinit();
|
|
self.pwd.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Print UTF-8 encoded string to the terminal.
|
|
pub fn printString(self: *Terminal, str: []const u8) !void {
|
|
const view = try std.unicode.Utf8View.init(str);
|
|
var it = view.iterator();
|
|
while (it.nextCodepoint()) |cp| {
|
|
switch (cp) {
|
|
'\n' => {
|
|
self.carriageReturn();
|
|
try self.linefeed();
|
|
},
|
|
|
|
else => try self.print(cp),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn print(self: *Terminal, c: u21) !void {
|
|
// log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x });
|
|
|
|
// If we're not on the main display, do nothing for now
|
|
if (self.status_display != .main) return;
|
|
|
|
// Our right margin depends where our cursor is now.
|
|
const right_limit = if (self.screen.cursor.x > self.scrolling_region.right)
|
|
self.cols
|
|
else
|
|
self.scrolling_region.right + 1;
|
|
|
|
// Perform grapheme clustering if grapheme support is enabled (mode 2027).
|
|
// This is MUCH slower than the normal path so the conditional below is
|
|
// purposely ordered in least-likely to most-likely so we can drop out
|
|
// as quickly as possible.
|
|
if (c > 255 and self.modes.get(.grapheme_cluster) and self.screen.cursor.x > 0) {
|
|
@panic("TODO: graphemes");
|
|
}
|
|
|
|
// Determine the width of this character so we can handle
|
|
// non-single-width characters properly. We have a fast-path for
|
|
// byte-sized characters since they're so common. We can ignore
|
|
// control characters because they're always filtered prior.
|
|
const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width);
|
|
|
|
// Note: it is possible to have a width of "3" and a width of "-1"
|
|
// from ziglyph. We should look into those cases and handle them
|
|
// appropriately.
|
|
assert(width <= 2);
|
|
// log.debug("c={x} width={}", .{ c, width });
|
|
|
|
// Attach zero-width characters to our cell as grapheme data.
|
|
if (width == 0) {
|
|
// If we have grapheme clustering enabled, we don't blindly attach
|
|
// any zero width character to our cells and we instead just ignore
|
|
// it.
|
|
if (self.modes.get(.grapheme_cluster)) return;
|
|
|
|
// If we're at cell zero, then this is malformed data and we don't
|
|
// print anything or even store this. Zero-width characters are ALWAYS
|
|
// attached to some other non-zero-width character at the time of
|
|
// writing.
|
|
if (self.screen.cursor.x == 0) {
|
|
log.warn("zero-width character with no prior character, ignoring", .{});
|
|
return;
|
|
}
|
|
|
|
@panic("TODO: zero-width characters");
|
|
}
|
|
|
|
// We have a printable character, save it
|
|
self.previous_char = c;
|
|
|
|
// If we're soft-wrapping, then handle that first.
|
|
if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) {
|
|
try self.printWrap();
|
|
}
|
|
|
|
// If we have insert mode enabled then we need to handle that. We
|
|
// only do insert mode if we're not at the end of the line.
|
|
if (self.modes.get(.insert) and
|
|
self.screen.cursor.x + width < self.cols)
|
|
{
|
|
@panic("TODO: insert mode");
|
|
//self.insertBlanks(width);
|
|
}
|
|
|
|
switch (width) {
|
|
// Single cell is very easy: just write in the cell
|
|
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 => 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,
|
|
}
|
|
|
|
// If we're at the column limit, then we need to wrap the next time.
|
|
// In this case, we don't move the cursor.
|
|
if (self.screen.cursor.x == right_limit - 1) {
|
|
self.screen.cursor.pending_wrap = true;
|
|
return;
|
|
}
|
|
|
|
// Move the cursor
|
|
self.screen.cursorRight(1);
|
|
}
|
|
|
|
fn printCell(
|
|
self: *Terminal,
|
|
unmapped_c: u21,
|
|
wide: Cell.Wide,
|
|
) void {
|
|
// TODO: charsets
|
|
const c: u21 = unmapped_c;
|
|
|
|
// 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.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.
|
|
if (self.screen.cursor.style_ref) |ref| {
|
|
ref.* += 1;
|
|
}
|
|
}
|
|
|
|
fn printWrap(self: *Terminal) !void {
|
|
self.screen.cursor.page_row.flags.wrap = true;
|
|
|
|
// Get the old semantic prompt so we can extend it to the next
|
|
// line. We need to do this before we index() because we may
|
|
// modify memory.
|
|
// TODO(mitchellh): before merge
|
|
//const old_prompt = row.getSemanticPrompt();
|
|
|
|
// Move to the next line
|
|
try self.index();
|
|
self.screen.cursorHorizontalAbsolute(self.scrolling_region.left);
|
|
|
|
// TODO(mitchellh): before merge
|
|
// New line must inherit semantic prompt of the old line
|
|
// const new_row = self.screen.getRow(.{ .active = self.screen.cursor.y });
|
|
// new_row.setSemanticPrompt(old_prompt);
|
|
self.screen.cursor.page_row.flags.wrap_continuation = true;
|
|
}
|
|
|
|
/// Carriage return moves the cursor to the first column.
|
|
pub fn carriageReturn(self: *Terminal) void {
|
|
// Always reset pending wrap state
|
|
self.screen.cursor.pending_wrap = false;
|
|
|
|
// In origin mode we always move to the left margin
|
|
self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin))
|
|
self.scrolling_region.left
|
|
else if (self.screen.cursor.x >= self.scrolling_region.left)
|
|
self.scrolling_region.left
|
|
else
|
|
0);
|
|
}
|
|
|
|
/// Linefeed moves the cursor to the next line.
|
|
pub fn linefeed(self: *Terminal) !void {
|
|
try self.index();
|
|
if (self.modes.get(.linefeed)) self.carriageReturn();
|
|
}
|
|
|
|
/// Backspace moves the cursor back a column (but not less than 0).
|
|
pub fn backspace(self: *Terminal) void {
|
|
self.cursorLeft(1);
|
|
}
|
|
|
|
/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1.
|
|
pub fn cursorLeft(self: *Terminal, count_req: usize) void {
|
|
// Wrapping behavior depends on various terminal modes
|
|
const WrapMode = enum { none, reverse, reverse_extended };
|
|
const wrap_mode: WrapMode = wrap_mode: {
|
|
if (!self.modes.get(.wraparound)) break :wrap_mode .none;
|
|
if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended;
|
|
if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse;
|
|
break :wrap_mode .none;
|
|
};
|
|
|
|
var count: size.CellCountInt = @intCast(@max(count_req, 1));
|
|
|
|
// If we are in no wrap mode, then we move the cursor left and exit
|
|
// since this is the fastest and most typical path.
|
|
if (wrap_mode == .none) {
|
|
self.screen.cursorLeft(count);
|
|
self.screen.cursor.pending_wrap = false;
|
|
return;
|
|
}
|
|
|
|
// If we have a pending wrap state and we are in either reverse wrap
|
|
// modes then we decrement the amount we move by one to match xterm.
|
|
if (self.screen.cursor.pending_wrap) {
|
|
count -= 1;
|
|
self.screen.cursor.pending_wrap = false;
|
|
}
|
|
|
|
// The margins we can move to.
|
|
const top = self.scrolling_region.top;
|
|
const bottom = self.scrolling_region.bottom;
|
|
const right_margin = self.scrolling_region.right;
|
|
const left_margin = if (self.screen.cursor.x < self.scrolling_region.left)
|
|
0
|
|
else
|
|
self.scrolling_region.left;
|
|
|
|
// Handle some edge cases when our cursor is already on the left margin.
|
|
if (self.screen.cursor.x == left_margin) {
|
|
switch (wrap_mode) {
|
|
// In reverse mode, if we're already before the top margin
|
|
// then we just set our cursor to the top-left and we're done.
|
|
.reverse => if (self.screen.cursor.y <= top) {
|
|
self.screen.cursorAbsolute(left_margin, top);
|
|
return;
|
|
},
|
|
|
|
// Handled in while loop
|
|
.reverse_extended => {},
|
|
|
|
// Handled above
|
|
.none => unreachable,
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
// We can move at most to the left margin.
|
|
const max = self.screen.cursor.x - left_margin;
|
|
|
|
// We want to move at most the number of columns we have left
|
|
// or our remaining count. Do the move.
|
|
const amount = @min(max, count);
|
|
count -= amount;
|
|
self.screen.cursorLeft(amount);
|
|
|
|
// If we have no more to move, then we're done.
|
|
if (count == 0) break;
|
|
|
|
// If we are at the top, then we are done.
|
|
if (self.screen.cursor.y == top) {
|
|
if (wrap_mode != .reverse_extended) break;
|
|
|
|
self.screen.cursorAbsolute(right_margin, bottom);
|
|
count -= 1;
|
|
continue;
|
|
}
|
|
|
|
// UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm
|
|
// and currently results in a crash in xterm. Given no other known
|
|
// terminal [to me] implements XTREVWRAP2, I decided to just mimick
|
|
// the behavior of xterm up and not including the crash by wrapping
|
|
// up to the (0, 0) and stopping there. My reasoning is that for an
|
|
// appropriately sized value of "count" this is the behavior that xterm
|
|
// would have. This is unit tested.
|
|
if (self.screen.cursor.y == 0) {
|
|
assert(self.screen.cursor.x == left_margin);
|
|
break;
|
|
}
|
|
|
|
// If our previous line is not wrapped then we are done.
|
|
if (wrap_mode != .reverse_extended) {
|
|
if (!self.screen.cursor.page_row.flags.wrap) break;
|
|
}
|
|
|
|
self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1);
|
|
count -= 1;
|
|
}
|
|
}
|
|
|
|
/// Move the cursor to the next line in the scrolling region, possibly scrolling.
|
|
///
|
|
/// If the cursor is outside of the scrolling region: move the cursor one line
|
|
/// down if it is not on the bottom-most line of the screen.
|
|
///
|
|
/// If the cursor is inside the scrolling region:
|
|
/// If the cursor is on the bottom-most line of the scrolling region:
|
|
/// invoke scroll up with amount=1
|
|
/// If the cursor is not on the bottom-most line of the scrolling region:
|
|
/// move the cursor one line down
|
|
///
|
|
/// This unsets the pending wrap state without wrapping.
|
|
pub fn index(self: *Terminal) !void {
|
|
// Unset pending wrap state
|
|
self.screen.cursor.pending_wrap = false;
|
|
|
|
// Outside of the scroll region we move the cursor one line down.
|
|
if (self.screen.cursor.y < self.scrolling_region.top or
|
|
self.screen.cursor.y > self.scrolling_region.bottom)
|
|
{
|
|
// We only move down if we're not already at the bottom of
|
|
// the screen.
|
|
if (self.screen.cursor.y < self.rows - 1) {
|
|
self.screen.cursorDown();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// If the cursor is inside the scrolling region and on the bottom-most
|
|
// line, then we scroll up. If our scrolling region is the full screen
|
|
// we create scrollback.
|
|
if (self.screen.cursor.y == self.scrolling_region.bottom and
|
|
self.screen.cursor.x >= self.scrolling_region.left and
|
|
self.screen.cursor.x <= self.scrolling_region.right)
|
|
{
|
|
// If our scrolling region is the full screen, we create scrollback.
|
|
// Otherwise, we simply scroll the region.
|
|
if (self.scrolling_region.top == 0 and
|
|
self.scrolling_region.bottom == self.rows - 1 and
|
|
self.scrolling_region.left == 0 and
|
|
self.scrolling_region.right == self.cols - 1)
|
|
{
|
|
try self.screen.cursorDownScroll();
|
|
} else {
|
|
@panic("TODO: scroll up");
|
|
//try self.scrollUp(1);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Increase cursor by 1, maximum to bottom of scroll region
|
|
if (self.screen.cursor.y < self.scrolling_region.bottom) {
|
|
self.screen.cursorDown();
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
|
|
}
|
|
|
|
/// DECSLRM
|
|
pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void {
|
|
// We must have this mode enabled to do anything
|
|
if (!self.modes.get(.enable_left_and_right_margin)) return;
|
|
|
|
const left = @max(1, left_req);
|
|
const right = @min(self.cols, if (right_req == 0) self.cols else right_req);
|
|
if (left >= right) return;
|
|
|
|
self.scrolling_region.left = @intCast(left - 1);
|
|
self.scrolling_region.right = @intCast(right - 1);
|
|
self.setCursorPos(1, 1);
|
|
}
|
|
|
|
/// Return the current string value of the terminal. Newlines are
|
|
/// encoded as "\n". This omits any formatting such as fg/bg.
|
|
///
|
|
/// The caller must free the string.
|
|
pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 {
|
|
return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
|
}
|
|
|
|
test "Terminal: input with no control characters" {
|
|
const alloc = testing.allocator;
|
|
var t = try init(alloc, 40, 40);
|
|
defer t.deinit(alloc);
|
|
|
|
// Basic grid writing
|
|
for ("hello") |c| try t.print(c);
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 5), t.screen.cursor.x);
|
|
{
|
|
const str = try t.plainString(alloc);
|
|
defer alloc.free(str);
|
|
try testing.expectEqualStrings("hello", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: input with basic wraparound" {
|
|
const alloc = testing.allocator;
|
|
var t = try init(alloc, 5, 40);
|
|
defer t.deinit(alloc);
|
|
|
|
// Basic grid writing
|
|
for ("helloworldabc12") |c| try t.print(c);
|
|
try testing.expectEqual(@as(usize, 2), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 4), t.screen.cursor.x);
|
|
try testing.expect(t.screen.cursor.pending_wrap);
|
|
{
|
|
const str = try t.plainString(alloc);
|
|
defer alloc.free(str);
|
|
try testing.expectEqualStrings("hello\nworld\nabc12", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: input that forces scroll" {
|
|
const alloc = testing.allocator;
|
|
var t = try init(alloc, 1, 5);
|
|
defer t.deinit(alloc);
|
|
|
|
// Basic grid writing
|
|
for ("abcdef") |c| try t.print(c);
|
|
try testing.expectEqual(@as(usize, 4), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
|
{
|
|
const str = try t.plainString(alloc);
|
|
defer alloc.free(str);
|
|
try testing.expectEqualStrings("b\nc\nd\ne\nf", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: zero-width character at start" {
|
|
var t = try init(testing.allocator, 80, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// This used to crash the terminal. This is not allowed so we should
|
|
// just ignore it.
|
|
try t.print(0x200D);
|
|
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
|
}
|
|
|
|
// https://github.com/mitchellh/ghostty/issues/1400
|
|
test "Terminal: print single very long line" {
|
|
var t = try init(testing.allocator, 5, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// This would crash for issue 1400. So the assertion here is
|
|
// 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 wide char in single-width terminal" {
|
|
var t = try init(testing.allocator, 1, 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, 0), t.screen.cursor.x);
|
|
try testing.expect(t.screen.cursor.pending_wrap);
|
|
|
|
{
|
|
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
|
const cell = list_cell.cell;
|
|
try testing.expectEqual(@as(u21, ' '), cell.codepoint);
|
|
try testing.expectEqual(Cell.Wide.narrow, 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);
|
|
}
|
|
}
|
|
|
|
test "Terminal: soft wrap" {
|
|
var t = try init(testing.allocator, 3, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// Basic grid writing
|
|
for ("hello") |c| try t.print(c);
|
|
try testing.expectEqual(@as(usize, 1), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("hel\nlo", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: disabled wraparound with wide char and one space" {
|
|
var t = try init(testing.allocator, 5, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
t.modes.set(.wraparound, false);
|
|
|
|
// This puts our cursor at the end and there is NO SPACE for a
|
|
// wide character.
|
|
try t.printString("AAAA");
|
|
try t.print(0x1F6A8); // Police car light
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 4), t.screen.cursor.x);
|
|
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("AAAA", str);
|
|
}
|
|
|
|
// Make sure we printed nothing
|
|
{
|
|
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .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: disabled wraparound with wide char and no space" {
|
|
var t = try init(testing.allocator, 5, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
t.modes.set(.wraparound, false);
|
|
|
|
// This puts our cursor at the end and there is NO SPACE for a
|
|
// wide character.
|
|
try t.printString("AAAAA");
|
|
try t.print(0x1F6A8); // Police car light
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 4), t.screen.cursor.x);
|
|
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("AAAAA", str);
|
|
}
|
|
|
|
{
|
|
const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?;
|
|
const cell = list_cell.cell;
|
|
try testing.expectEqual(@as(u21, 'A'), cell.codepoint);
|
|
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
|
|
}
|
|
}
|
|
|
|
test "Terminal: print right margin wrap" {
|
|
var t = try init(testing.allocator, 10, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
try t.printString("123456789");
|
|
t.modes.set(.enable_left_and_right_margin, true);
|
|
t.setLeftAndRightMargin(3, 5);
|
|
t.setCursorPos(1, 5);
|
|
try t.printString("XY");
|
|
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("1234X6789\n Y", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: print right margin outside" {
|
|
var t = try init(testing.allocator, 10, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
try t.printString("123456789");
|
|
t.modes.set(.enable_left_and_right_margin, true);
|
|
t.setLeftAndRightMargin(3, 5);
|
|
t.setCursorPos(1, 6);
|
|
try t.printString("XY");
|
|
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("12345XY89", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: print right margin outside wrap" {
|
|
var t = try init(testing.allocator, 10, 5);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
try t.printString("123456789");
|
|
t.modes.set(.enable_left_and_right_margin, true);
|
|
t.setLeftAndRightMargin(3, 5);
|
|
t.setCursorPos(1, 10);
|
|
try t.printString("XY");
|
|
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("123456789X\n Y", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: linefeed and carriage return" {
|
|
var t = try init(testing.allocator, 80, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// Basic grid writing
|
|
for ("hello") |c| try t.print(c);
|
|
t.carriageReturn();
|
|
try t.linefeed();
|
|
for ("world") |c| try t.print(c);
|
|
try testing.expectEqual(@as(usize, 1), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 5), t.screen.cursor.x);
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("hello\nworld", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: linefeed unsets pending wrap" {
|
|
var t = try init(testing.allocator, 5, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// Basic grid writing
|
|
for ("hello") |c| try t.print(c);
|
|
try testing.expect(t.screen.cursor.pending_wrap == true);
|
|
try t.linefeed();
|
|
try testing.expect(t.screen.cursor.pending_wrap == false);
|
|
}
|
|
|
|
test "Terminal: linefeed mode automatic carriage return" {
|
|
var t = try init(testing.allocator, 10, 10);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// Basic grid writing
|
|
t.modes.set(.linefeed, true);
|
|
try t.printString("123456");
|
|
try t.linefeed();
|
|
try t.print('X');
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("123456\nX", str);
|
|
}
|
|
}
|
|
|
|
test "Terminal: carriage return unsets pending wrap" {
|
|
var t = try init(testing.allocator, 5, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// Basic grid writing
|
|
for ("hello") |c| try t.print(c);
|
|
try testing.expect(t.screen.cursor.pending_wrap == true);
|
|
t.carriageReturn();
|
|
try testing.expect(t.screen.cursor.pending_wrap == false);
|
|
}
|
|
|
|
test "Terminal: carriage return origin mode moves to left margin" {
|
|
var t = try init(testing.allocator, 5, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
t.modes.set(.origin, true);
|
|
t.screen.cursor.x = 0;
|
|
t.scrolling_region.left = 2;
|
|
t.carriageReturn();
|
|
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
|
|
}
|
|
|
|
test "Terminal: carriage return left of left margin moves to zero" {
|
|
var t = try init(testing.allocator, 5, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
t.screen.cursor.x = 1;
|
|
t.scrolling_region.left = 2;
|
|
t.carriageReturn();
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.x);
|
|
}
|
|
|
|
test "Terminal: carriage return right of left margin moves to left margin" {
|
|
var t = try init(testing.allocator, 5, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
t.screen.cursor.x = 3;
|
|
t.scrolling_region.left = 2;
|
|
t.carriageReturn();
|
|
try testing.expectEqual(@as(usize, 2), t.screen.cursor.x);
|
|
}
|
|
|
|
test "Terminal: backspace" {
|
|
var t = try init(testing.allocator, 80, 80);
|
|
defer t.deinit(testing.allocator);
|
|
|
|
// BS
|
|
for ("hello") |c| try t.print(c);
|
|
t.backspace();
|
|
try t.print('y');
|
|
try testing.expectEqual(@as(usize, 0), t.screen.cursor.y);
|
|
try testing.expectEqual(@as(usize, 5), t.screen.cursor.x);
|
|
{
|
|
const str = try t.plainString(testing.allocator);
|
|
defer testing.allocator.free(str);
|
|
try testing.expectEqualStrings("helly", str);
|
|
}
|
|
}
|