From a1c14d18590bb842d91f806bbcf09d4c9379ff78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Feb 2024 21:44:37 -0800 Subject: [PATCH] terminal/new: print single lines of ascii chars lol --- src/terminal/main.zig | 1 + src/terminal/new/Screen.zig | 12 +- src/terminal/new/Terminal.zig | 334 ++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/terminal/new/Terminal.zig diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 79e424b93..481b32090 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -55,6 +55,7 @@ test { _ = @import("new/page.zig"); _ = @import("new/PageList.zig"); _ = @import("new/Screen.zig"); + _ = @import("new/Terminal.zig"); _ = @import("new/point.zig"); _ = @import("new/size.zig"); _ = @import("new/style.zig"); diff --git a/src/terminal/new/Screen.zig b/src/terminal/new/Screen.zig index 0ee165881..4675bfbcd 100644 --- a/src/terminal/new/Screen.zig +++ b/src/terminal/new/Screen.zig @@ -69,6 +69,16 @@ pub fn deinit(self: *Screen) void { self.pages.deinit(); } +/// 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); + + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell + 1); + self.cursor.x += 1; +} + /// Dump the screen to a string. The writer given should be buffered; /// this function does not attempt to efficiently write and generally writes /// one byte at a time. @@ -107,7 +117,7 @@ pub fn dumpString( } } -fn dumpStringAlloc( +pub fn dumpStringAlloc( self: *const Screen, alloc: Allocator, tl: point.Point, diff --git a/src/terminal/new/Terminal.zig b/src/terminal/new/Terminal.zig new file mode 100644 index 000000000..3ae533112 --- /dev/null +++ b/src/terminal/new/Terminal.zig @@ -0,0 +1,334 @@ +//! 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 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 pagepkg = @import("page.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: usize, +cols: usize, + +/// 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: usize, + bottom: usize, + + // Left/right scroll regions. + // Precondition: right > left + // Precondition: right <= cols - 1 + left: usize, + right: usize, +}; + +/// Initialize a new terminal. +pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { + return Terminal{ + .cols = cols, + .rows = rows, + .active_screen = .primary, + // TODO: configurable scrollback + .screen = try Screen.init(alloc, rows, cols, 10000), + // No scrollback for the alternate screen + .secondary_screen = try Screen.init(alloc, rows, cols, 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; +} + +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) { + @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)) { + @panic("TODO: wraparound"); + } + + // 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 }), + + // 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"), + + 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) { + self.screen.cursor.pending_wrap = true; + return; + } + + // Move the cursor + self.screen.cursorRight(); +} + +fn printCell(self: *Terminal, unmapped_c: u21) 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(); + // } + // } + + // If the prior value had graphemes, clear those + //if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); + + // Write + self.screen.cursor.page_cell.* = .{ .codepoint = c }; + //cell.* = self.screen.cursor.pen; + //cell.char = @intCast(c); +} + +/// 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 testing = std.testing; + 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); + } +}