//! 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 [ 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); } }