From 0f5841baca83f5df752dc26b33fee1b7beb36dd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Mar 2024 19:44:58 -0800 Subject: [PATCH] terminal2: start Selection --- src/terminal/Selection.zig | 2 + src/terminal2/PageList.zig | 24 + src/terminal2/Parser.zig | 794 ++++++++++++ src/terminal2/Screen.zig | 2 +- src/terminal2/Selection.zig | 458 +++++++ src/terminal2/UTF8Decoder.zig | 142 +++ src/terminal2/apc.zig | 137 +++ src/terminal2/dcs.zig | 309 +++++ src/terminal2/device_status.zig | 67 + src/terminal2/main.zig | 1 + src/terminal2/osc.zig | 1274 +++++++++++++++++++ src/terminal2/parse_table.zig | 389 ++++++ src/terminal2/sanitize.zig | 13 + src/terminal2/stream.zig | 2014 +++++++++++++++++++++++++++++++ 14 files changed, 5625 insertions(+), 1 deletion(-) create mode 100644 src/terminal2/Parser.zig create mode 100644 src/terminal2/Selection.zig create mode 100644 src/terminal2/UTF8Decoder.zig create mode 100644 src/terminal2/apc.zig create mode 100644 src/terminal2/dcs.zig create mode 100644 src/terminal2/device_status.zig create mode 100644 src/terminal2/osc.zig create mode 100644 src/terminal2/parse_table.zig create mode 100644 src/terminal2/sanitize.zig create mode 100644 src/terminal2/stream.zig diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index fb83ebbea..6dc2c77ed 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -759,6 +759,7 @@ test "Selection: within" { } } +// X test "Selection: order, standard" { const testing = std.testing; { @@ -808,6 +809,7 @@ test "Selection: order, standard" { } } +// X test "Selection: order, rectangle" { const testing = std.testing; // Conventions: diff --git a/src/terminal2/PageList.zig b/src/terminal2/PageList.zig index c7d854274..eeff789a9 100644 --- a/src/terminal2/PageList.zig +++ b/src/terminal2/PageList.zig @@ -1811,6 +1811,30 @@ pub const Pin = struct { return false; } + /// Returns true if self is before other. This is very expensive since + /// it requires traversing the linked list of pages. This should not + /// be called in performance critical paths. + pub fn isBefore(self: Pin, other: Pin) bool { + if (self.page == other.page) { + if (self.y < other.y) return true; + if (self.y > other.y) return false; + return self.x < other.x; + } + + var page = self.page.next; + while (page) |p| : (page = p.next) { + if (p == other.page) return true; + } + + return false; + } + + pub fn eql(self: Pin, other: Pin) bool { + return self.page == other.page and + self.y == other.y and + self.x == other.x; + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { diff --git a/src/terminal2/Parser.zig b/src/terminal2/Parser.zig new file mode 100644 index 000000000..f160619e2 --- /dev/null +++ b/src/terminal2/Parser.zig @@ -0,0 +1,794 @@ +//! VT-series parser for escape and control sequences. +//! +//! This is implemented directly as the state machine described on +//! vt100.net: https://vt100.net/emu/dec_ansi_parser +const Parser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const testing = std.testing; +const table = @import("parse_table.zig").table; +const osc = @import("osc.zig"); + +const log = std.log.scoped(.parser); + +/// States for the state machine +pub const State = enum { + ground, + escape, + escape_intermediate, + csi_entry, + csi_intermediate, + csi_param, + csi_ignore, + dcs_entry, + dcs_param, + dcs_intermediate, + dcs_passthrough, + dcs_ignore, + osc_string, + sos_pm_apc_string, +}; + +/// Transition action is an action that can be taken during a state +/// transition. This is more of an internal action, not one used by +/// end users, typically. +pub const TransitionAction = enum { + none, + ignore, + print, + execute, + collect, + param, + esc_dispatch, + csi_dispatch, + put, + osc_put, + apc_put, +}; + +/// Action is the action that a caller of the parser is expected to +/// take as a result of some input character. +pub const Action = union(enum) { + pub const Tag = std.meta.FieldEnum(Action); + + /// Draw character to the screen. This is a unicode codepoint. + print: u21, + + /// Execute the C0 or C1 function. + execute: u8, + + /// Execute the CSI command. Note that pointers within this + /// structure are only valid until the next call to "next". + csi_dispatch: CSI, + + /// Execute the ESC command. + esc_dispatch: ESC, + + /// Execute the OSC command. + osc_dispatch: osc.Command, + + /// DCS-related events. + dcs_hook: DCS, + dcs_put: u8, + dcs_unhook: void, + + /// APC data + apc_start: void, + apc_put: u8, + apc_end: void, + + pub const CSI = struct { + intermediates: []u8, + params: []u16, + final: u8, + sep: Sep, + + /// The separator used for CSI params. + pub const Sep = enum { semicolon, colon }; + + // Implement formatter for logging + pub fn format( + self: CSI, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + try std.fmt.format(writer, "ESC [ {s} {any} {c}", .{ + self.intermediates, + self.params, + self.final, + }); + } + }; + + pub const ESC = struct { + intermediates: []u8, + final: u8, + + // Implement formatter for logging + pub fn format( + self: ESC, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + try std.fmt.format(writer, "ESC {s} {c}", .{ + self.intermediates, + self.final, + }); + } + }; + + pub const DCS = struct { + intermediates: []const u8 = "", + params: []const u16 = &.{}, + final: u8, + }; + + // Implement formatter for logging. This is mostly copied from the + // std.fmt implementation, but we modify it slightly so that we can + // print out custom formats for some of our primitives. + pub fn format( + self: Action, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + const T = Action; + const info = @typeInfo(T).Union; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + // If this is the active field... + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (@TypeOf(value)) { + // Unicode + u21 => try std.fmt.format(writer, "'{u}' (U+{X})", .{ value, value }), + + // Byte + u8 => try std.fmt.format(writer, "0x{x}", .{value}), + + // Note: we don't do ASCII (u8) because there are a lot + // of invisible characters we don't want to handle right + // now. + + // All others do the default behavior + else => try std.fmt.formatType( + @field(self, u_field.name), + "any", + opts, + writer, + 3, + ), + } + } + } + + try writer.writeAll(" }"); + } else { + try format(writer, "@{x}", .{@intFromPtr(&self)}); + } + } +}; + +/// Keeps track of the parameter sep used for CSI params. We allow colons +/// to be used ONLY by the 'm' CSI action. +pub const ParamSepState = enum(u8) { + none = 0, + semicolon = ';', + colon = ':', + mixed = 1, +}; + +/// Maximum number of intermediate characters during parsing. This is +/// 4 because we also use the intermediates array for UTF8 decoding which +/// can be at most 4 bytes. +const MAX_INTERMEDIATE = 4; +const MAX_PARAMS = 16; + +/// Current state of the state machine +state: State = .ground, + +/// Intermediate tracking. +intermediates: [MAX_INTERMEDIATE]u8 = undefined, +intermediates_idx: u8 = 0, + +/// Param tracking, building +params: [MAX_PARAMS]u16 = undefined, +params_idx: u8 = 0, +params_sep: ParamSepState = .none, +param_acc: u16 = 0, +param_acc_idx: u8 = 0, + +/// Parser for OSC sequences +osc_parser: osc.Parser = .{}, + +pub fn init() Parser { + return .{}; +} + +pub fn deinit(self: *Parser) void { + self.osc_parser.deinit(); +} + +/// Next consumes the next character c and returns the actions to execute. +/// Up to 3 actions may need to be executed -- in order -- representing +/// the state exit, transition, and entry actions. +pub fn next(self: *Parser, c: u8) [3]?Action { + const effect = table[c][@intFromEnum(self.state)]; + + // log.info("next: {x}", .{c}); + + const next_state = effect.state; + const action = effect.action; + + // After generating the actions, we set our next state. + defer self.state = next_state; + + // When going from one state to another, the actions take place in this order: + // + // 1. exit action from old state + // 2. transition action + // 3. entry action to new state + return [3]?Action{ + // Exit depends on current state + if (self.state == next_state) null else switch (self.state) { + .osc_string => if (self.osc_parser.end(c)) |cmd| + Action{ .osc_dispatch = cmd } + else + null, + .dcs_passthrough => Action{ .dcs_unhook = {} }, + .sos_pm_apc_string => Action{ .apc_end = {} }, + else => null, + }, + + self.doAction(action, c), + + // Entry depends on new state + if (self.state == next_state) null else switch (next_state) { + .escape, .dcs_entry, .csi_entry => clear: { + self.clear(); + break :clear null; + }, + .osc_string => osc_string: { + self.osc_parser.reset(); + break :osc_string null; + }, + .dcs_passthrough => Action{ + .dcs_hook = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .params = self.params[0..self.params_idx], + .final = c, + }, + }, + .sos_pm_apc_string => Action{ .apc_start = {} }, + else => null, + }, + }; +} + +pub fn collect(self: *Parser, c: u8) void { + if (self.intermediates_idx >= MAX_INTERMEDIATE) { + log.warn("invalid intermediates count", .{}); + return; + } + + self.intermediates[self.intermediates_idx] = c; + self.intermediates_idx += 1; +} + +fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { + return switch (action) { + .none, .ignore => null, + .print => Action{ .print = c }, + .execute => Action{ .execute = c }, + .collect => collect: { + self.collect(c); + break :collect null; + }, + .param => param: { + // Semicolon separates parameters. If we encounter a semicolon + // we need to store and move on to the next parameter. + if (c == ';' or c == ':') { + // Ignore too many parameters + if (self.params_idx >= MAX_PARAMS) break :param null; + + // If this is our first time seeing a parameter, we track + // the separator used so that we can't mix separators later. + if (self.params_idx == 0) self.params_sep = @enumFromInt(c); + if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed; + + // Set param final value + self.params[self.params_idx] = self.param_acc; + self.params_idx += 1; + + // Reset current param value to 0 + self.param_acc = 0; + self.param_acc_idx = 0; + break :param null; + } + + // A numeric value. Add it to our accumulator. + if (self.param_acc_idx > 0) { + self.param_acc *|= 10; + } + self.param_acc +|= c - '0'; + + // Increment our accumulator index. If we overflow then + // we're out of bounds and we exit immediately. + self.param_acc_idx, const overflow = @addWithOverflow(self.param_acc_idx, 1); + if (overflow > 0) break :param null; + + // The client is expected to perform no action. + break :param null; + }, + .osc_put => osc_put: { + self.osc_parser.next(c); + break :osc_put null; + }, + .csi_dispatch => csi_dispatch: { + // Ignore too many parameters + if (self.params_idx >= MAX_PARAMS) break :csi_dispatch null; + + // Finalize parameters if we have one + if (self.param_acc_idx > 0) { + self.params[self.params_idx] = self.param_acc; + self.params_idx += 1; + } + + const result: Action = .{ + .csi_dispatch = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .params = self.params[0..self.params_idx], + .final = c, + .sep = switch (self.params_sep) { + .none, .semicolon => .semicolon, + .colon => .colon, + + // There is nothing that treats mixed separators specially + // afaik so we just treat it as a semicolon. + .mixed => .semicolon, + }, + }, + }; + + // We only allow colon or mixed separators for the 'm' command. + switch (self.params_sep) { + .none => {}, + .semicolon => {}, + .colon, .mixed => if (c != 'm') { + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {}", + .{result}, + ); + break :csi_dispatch null; + }, + } + + break :csi_dispatch result; + }, + .esc_dispatch => Action{ + .esc_dispatch = .{ + .intermediates = self.intermediates[0..self.intermediates_idx], + .final = c, + }, + }, + .put => Action{ .dcs_put = c }, + .apc_put => Action{ .apc_put = c }, + }; +} + +pub fn clear(self: *Parser) void { + self.intermediates_idx = 0; + self.params_idx = 0; + self.params_sep = .none; + self.param_acc = 0; + self.param_acc_idx = 0; +} + +test { + var p = init(); + _ = p.next(0x9E); + try testing.expect(p.state == .sos_pm_apc_string); + _ = p.next(0x9C); + try testing.expect(p.state == .ground); + + { + const a = p.next('a'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .print); + try testing.expect(a[2] == null); + } + + { + const a = p.next(0x19); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .execute); + try testing.expect(a[2] == null); + } +} + +test "esc: ESC ( B" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('('); + + { + const a = p.next('B'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .esc_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.esc_dispatch; + try testing.expect(d.final == 'B'); + try testing.expect(d.intermediates.len == 1); + try testing.expect(d.intermediates[0] == '('); + } +} + +test "csi: ESC [ H" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(0x5B); + + { + const a = p.next(0x48); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 0x48); + try testing.expect(d.params.len == 0); + } +} + +test "csi: ESC [ 1 ; 4 H" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(0x5B); + _ = p.next(0x31); // 1 + _ = p.next(0x3B); // ; + _ = p.next(0x34); // 4 + + { + const a = p.next(0x48); // H + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'H'); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 1), d.params[0]); + try testing.expectEqual(@as(u16, 4), d.params[1]); + } +} + +test "csi: SGR ESC [ 38 : 2 m" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + _ = p.next('3'); + _ = p.next('8'); + _ = p.next(':'); + _ = p.next('2'); + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 38), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + } +} + +test "csi: SGR colon followed by semicolon" { + var p = init(); + _ = p.next(0x1B); + for ("[48:2") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } + + _ = p.next(0x1B); + _ = p.next('['); + { + const a = p.next('H'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } +} + +test "csi: SGR mixed colon and semicolon" { + var p = init(); + _ = p.next(0x1B); + for ("[38:5:1;48:5:0") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + } +} + +test "csi: SGR ESC [ 48 : 2 m" { + var p = init(); + _ = p.next(0x1B); + for ("[48:2:240:143:104") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 5); + try testing.expectEqual(@as(u16, 48), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expectEqual(@as(u16, 240), d.params[2]); + try testing.expectEqual(@as(u16, 143), d.params[3]); + try testing.expectEqual(@as(u16, 104), d.params[4]); + } +} + +test "csi: SGR ESC [4:3m colon" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + _ = p.next('4'); + _ = p.next(':'); + _ = p.next('3'); + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 2); + try testing.expectEqual(@as(u16, 4), d.params[0]); + try testing.expectEqual(@as(u16, 3), d.params[1]); + } +} + +test "csi: SGR with many blank and colon" { + var p = init(); + _ = p.next(0x1B); + for ("[58:2::240:143:104") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expect(d.sep == .colon); + try testing.expect(d.params.len == 6); + try testing.expectEqual(@as(u16, 58), d.params[0]); + try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expectEqual(@as(u16, 0), d.params[2]); + try testing.expectEqual(@as(u16, 240), d.params[3]); + try testing.expectEqual(@as(u16, 143), d.params[4]); + try testing.expectEqual(@as(u16, 104), d.params[5]); + } +} + +test "csi: colon for non-m final" { + var p = init(); + _ = p.next(0x1B); + for ("[38:2h") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + try testing.expect(p.state == .ground); +} + +test "csi: request mode decrqm" { + var p = init(); + _ = p.next(0x1B); + for ("[?2026$") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('p'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'p'); + try testing.expectEqual(@as(usize, 2), d.intermediates.len); + try testing.expectEqual(@as(usize, 1), d.params.len); + try testing.expectEqual(@as(u16, '?'), d.intermediates[0]); + try testing.expectEqual(@as(u16, '$'), d.intermediates[1]); + try testing.expectEqual(@as(u16, 2026), d.params[0]); + } +} + +test "csi: change cursor" { + var p = init(); + _ = p.next(0x1B); + for ("[3 ") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('q'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'q'); + try testing.expectEqual(@as(usize, 1), d.intermediates.len); + try testing.expectEqual(@as(usize, 1), d.params.len); + try testing.expectEqual(@as(u16, ' '), d.intermediates[0]); + try testing.expectEqual(@as(u16, 3), d.params[0]); + } +} + +test "osc: change window title" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('0'); + _ = p.next(';'); + _ = p.next('a'); + _ = p.next('b'); + _ = p.next('c'); + + { + const a = p.next(0x07); // BEL + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); + } +} + +test "osc: change window title (end in esc)" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('0'); + _ = p.next(';'); + _ = p.next('a'); + _ = p.next('b'); + _ = p.next('c'); + + { + const a = p.next(0x1B); + _ = p.next('\\'); + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("abc", cmd.change_window_title); + } +} + +// https://github.com/darrenstarr/VtNetCore/pull/14 +// Saw this on HN, decided to add a test case because why not. +test "osc: 112 incomplete sequence" { + var p = init(); + _ = p.next(0x1B); + _ = p.next(']'); + _ = p.next('1'); + _ = p.next('1'); + _ = p.next('2'); + + { + const a = p.next(0x07); + try testing.expect(p.state == .ground); + try testing.expect(a[0].? == .osc_dispatch); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + + const cmd = a[0].?.osc_dispatch; + try testing.expect(cmd == .reset_color); + try testing.expectEqual(cmd.reset_color.kind, .cursor); + } +} + +test "csi: too many params" { + var p = init(); + _ = p.next(0x1B); + _ = p.next('['); + for (0..100) |_| { + _ = p.next('1'); + _ = p.next(';'); + } + _ = p.next('1'); + + { + const a = p.next('C'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } +} diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig index 3ba171c45..17fd1301a 100644 --- a/src/terminal2/Screen.zig +++ b/src/terminal2/Screen.zig @@ -925,7 +925,7 @@ pub fn dumpStringAlloc( /// This is basically a really jank version of Terminal.printString. We /// have to reimplement it here because we want a way to print to the screen /// to test it but don't want all the features of Terminal. -fn testWriteString(self: *Screen, text: []const u8) !void { +pub fn testWriteString(self: *Screen, text: []const u8) !void { const view = try std.unicode.Utf8View.init(text); var iter = view.iterator(); while (iter.nextCodepoint()) |c| { diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig new file mode 100644 index 000000000..440177604 --- /dev/null +++ b/src/terminal2/Selection.zig @@ -0,0 +1,458 @@ +//! Represents a single selection within the terminal (i.e. a highlight region). +const Selection = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const point = @import("point.zig"); +const PageList = @import("PageList.zig"); +const Screen = @import("Screen.zig"); +const Pin = PageList.Pin; + +// NOTE(mitchellh): I'm not very happy with how this is implemented, because +// the ordering operations which are used frequently require using +// pointFromPin which -- at the time of writing this -- is slow. The overall +// style of this struct is due to porting it from the previous implementation +// which had an efficient ordering operation. +// +// While reimplementing this, there were too many callers that already +// depended on this behavior so I kept it despite the inefficiency. In the +// future, we should take a look at this again! + +/// Start and end of the selection. There is no guarantee that +/// start is before end or vice versa. If a user selects backwards, +/// start will be after end, and vice versa. Use the struct functions +/// to not have to worry about this. +/// +/// These are always tracked pins so that they automatically update as +/// the screen they're attached to gets scrolled, erased, etc. +start: *Pin, +end: *Pin, + +/// Whether or not this selection refers to a rectangle, rather than whole +/// lines of a buffer. In this mode, start and end refer to the top left and +/// bottom right of the rectangle, or vice versa if the selection is backwards. +rectangle: bool = false, + +/// Initialize a new selection with the given start and end pins on +/// the screen. The screen will be used for pin tracking. +pub fn init( + s: *Screen, + start: Pin, + end: Pin, + rect: bool, +) !Selection { + // Track our pins + const tracked_start = try s.pages.trackPin(start); + errdefer s.pages.untrackPin(tracked_start); + const tracked_end = try s.pages.trackPin(end); + errdefer s.pages.untrackPin(tracked_end); + + return .{ + .start = tracked_start, + .end = tracked_end, + .rectangle = rect, + }; +} + +pub fn deinit( + self: Selection, + s: *Screen, +) void { + s.pages.untrackPin(self.start); + s.pages.untrackPin(self.end); +} + +/// The order of the selection: +/// +/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). +/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). +/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). +/// +/// For regular selections, the above also holds for top-right to bottom-left +/// (forward) and bottom-left to top-right (reverse). However, for rectangle +/// selections, both of these selections are *mirrored* as orientation +/// operations only flip the x or y axis, not both. Depending on the y axis +/// direction, this is either mirrored_forward or mirrored_reverse. +/// +pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; + +pub fn order(self: Selection, s: *const Screen) Order { + const start_pt = s.pages.pointFromPin(.screen, self.start.*).?.screen; + const end_pt = s.pages.pointFromPin(.screen, self.end.*).?.screen; + + if (self.rectangle) { + // Reverse (also handles single-column) + if (start_pt.y > end_pt.y and start_pt.x >= end_pt.x) return .reverse; + if (start_pt.y >= end_pt.y and start_pt.x > end_pt.x) return .reverse; + + // Mirror, bottom-left to top-right + if (start_pt.y > end_pt.y and start_pt.x < end_pt.x) return .mirrored_reverse; + + // Mirror, top-right to bottom-left + if (start_pt.y < end_pt.y and start_pt.x > end_pt.x) return .mirrored_forward; + + // Forward + return .forward; + } + + if (start_pt.y < end_pt.y) return .forward; + if (start_pt.y > end_pt.y) return .reverse; + if (start_pt.x <= end_pt.x) return .forward; + return .reverse; +} + +/// Possible adjustments to the selection. +pub const Adjustment = enum { + left, + right, + up, + down, + home, + end, + page_up, + page_down, +}; + +/// Adjust the selection by some given adjustment. An adjustment allows +/// a selection to be expanded slightly left, right, up, down, etc. +pub fn adjust( + self: *Selection, + s: *const Screen, + adjustment: Adjustment, +) void { + _ = self; + _ = s; + + //const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; + + // Note that we always adjusts "end" because end always represents + // the last point of the selection by mouse, not necessarilly the + // top/bottom visually. So this results in the right behavior + // whether the user drags up or down. + switch (adjustment) { + // .up => if (result.end.y == 0) { + // result.end.x = 0; + // } else { + // result.end.y -= 1; + // }, + // + // .down => if (result.end.y >= screen_end) { + // result.end.y = screen_end; + // result.end.x = screen.cols - 1; + // } else { + // result.end.y += 1; + // }, + // + // .left => { + // // Step left, wrapping to the next row up at the start of each new line, + // // until we find a non-empty cell. + // // + // // This iterator emits the start point first, throw it out. + // var iterator = result.end.iterator(screen, .left_up); + // _ = iterator.next(); + // while (iterator.next()) |next| { + // if (screen.getCell( + // .screen, + // next.y, + // next.x, + // ).char != 0) { + // result.end = next; + // break; + // } + // } + // }, + + // .right => { + // // Step right, wrapping to the next row down at the start of each new line, + // // until we find a non-empty cell. + // var iterator = result.end.iterator(screen, .right_down); + // _ = iterator.next(); + // while (iterator.next()) |next| { + // if (next.y > screen_end) break; + // if (screen.getCell( + // .screen, + // next.y, + // next.x, + // ).char != 0) { + // if (next.y > screen_end) { + // result.end.y = screen_end; + // } else { + // result.end = next; + // } + // break; + // } + // } + // }, + // + // .page_up => if (screen.rows > result.end.y) { + // result.end.y = 0; + // result.end.x = 0; + // } else { + // result.end.y -= screen.rows; + // }, + // + // .page_down => if (screen.rows > screen_end - result.end.y) { + // result.end.y = screen_end; + // result.end.x = screen.cols - 1; + // } else { + // result.end.y += screen.rows; + // }, + // + // .home => { + // result.end.y = 0; + // result.end.x = 0; + // }, + // + // .end => { + // result.end.y = screen_end; + // result.end.x = screen.cols - 1; + //}, + + else => @panic("TODO"), + } +} + +test "Selection: adjust right" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC1234\nD5678"); + + // // Simple movement right + // { + // var sel = try Selection.init( + // &s, + // s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + // s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + // false, + // ); + // defer sel.deinit(&s); + // sel.adjust(&s, .right); + // + // try testing.expectEqual(point.Point{ .screen = .{ + // .x = 5, + // .y = 1, + // } }, s.pages.pointFromPin(.screen, sel.start.*).?); + // try testing.expectEqual(point.Point{ .screen = .{ + // .x = 4, + // .y = 3, + // } }, s.pages.pointFromPin(.screen, sel.end.*).?); + // } + + // // Already at end of the line. + // { + // const sel = (Selection{ + // .start = .{ .x = 5, .y = 1 }, + // .end = .{ .x = 4, .y = 2 }, + // }).adjust(&screen, .right); + // + // try testing.expectEqual(Selection{ + // .start = .{ .x = 5, .y = 1 }, + // .end = .{ .x = 0, .y = 3 }, + // }, sel); + // } + // + // // Already at end of the screen + // { + // const sel = (Selection{ + // .start = .{ .x = 5, .y = 1 }, + // .end = .{ .x = 4, .y = 3 }, + // }).adjust(&screen, .right); + // + // try testing.expectEqual(Selection{ + // .start = .{ .x = 5, .y = 1 }, + // .end = .{ .x = 4, .y = 3 }, + // }, sel); + // } +} + +test "Selection: order, standard" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + + { + // forward, multi-line + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // reverse, multi-line + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); + } + { + // forward, same-line + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // forward, single char + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // reverse, single line + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); + } +} + +test "Selection: order, rectangle" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + + // Conventions: + // TL - top left + // BL - bottom left + // TR - top right + // BR - bottom right + { + // forward (TL -> BR) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // reverse (BR -> TL) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); + } + { + // mirrored_forward (TR -> BL) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .mirrored_forward); + } + { + // mirrored_reverse (BL -> TR) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .mirrored_reverse); + } + { + // forward, single line (left -> right ) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // reverse, single line (right -> left) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); + } + { + // forward, single column (top -> bottom) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } + { + // reverse, single column (bottom -> top) + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .reverse); + } + { + // forward, single cell + const sel = try Selection.init( + &s, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + + try testing.expect(sel.order(&s) == .forward); + } +} diff --git a/src/terminal2/UTF8Decoder.zig b/src/terminal2/UTF8Decoder.zig new file mode 100644 index 000000000..6bb0d9815 --- /dev/null +++ b/src/terminal2/UTF8Decoder.zig @@ -0,0 +1,142 @@ +//! DFA-based non-allocating error-replacing UTF-8 decoder. +//! +//! This implementation is based largely on the excellent work of +//! Bjoern Hoehrmann, with slight modifications to support error- +//! replacement. +//! +//! For details on Bjoern's DFA-based UTF-8 decoder, see +//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed) +const UTF8Decoder = @This(); + +const std = @import("std"); +const testing = std.testing; + +const log = std.log.scoped(.utf8decoder); + +// zig fmt: off +const char_classes = [_]u4{ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, + 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, +}; + +const transitions = [_]u8 { + 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, + 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, + 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, + 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, + 12,36,12,12,12,12,12,12,12,12,12,12, +}; +// zig fmt: on + +// DFA states +const ACCEPT_STATE = 0; +const REJECT_STATE = 12; + +// This is where we accumulate our current codepoint. +accumulator: u21 = 0, +// The internal state of the DFA. +state: u8 = ACCEPT_STATE, + +/// Takes the next byte in the utf-8 sequence and emits a tuple of +/// - The codepoint that was generated, if there is one. +/// - A boolean that indicates whether the provided byte was consumed. +/// +/// The only case where the byte is not consumed is if an ill-formed +/// sequence is reached, in which case a replacement character will be +/// emitted and the byte will not be consumed. +/// +/// If the byte is not consumed, the caller is responsible for calling +/// again with the same byte before continuing. +pub inline fn next(self: *UTF8Decoder, byte: u8) struct { ?u21, bool } { + const char_class = char_classes[byte]; + + const initial_state = self.state; + + if (self.state != ACCEPT_STATE) { + self.accumulator <<= 6; + self.accumulator |= (byte & 0x3F); + } else { + self.accumulator = (@as(u21, 0xFF) >> char_class) & (byte); + } + + self.state = transitions[self.state + char_class]; + + if (self.state == ACCEPT_STATE) { + defer self.accumulator = 0; + + // Emit the fully decoded codepoint. + return .{ self.accumulator, true }; + } else if (self.state == REJECT_STATE) { + self.accumulator = 0; + self.state = ACCEPT_STATE; + // Emit a replacement character. If we rejected the first byte + // in a sequence, then it was consumed, otherwise it was not. + return .{ 0xFFFD, initial_state == ACCEPT_STATE }; + } else { + // Emit nothing, we're in the middle of a sequence. + return .{ null, true }; + } +} + +test "ASCII" { + var d: UTF8Decoder = .{}; + var out: [13]u8 = undefined; + for ("Hello, World!", 0..) |byte, i| { + const res = d.next(byte); + try testing.expect(res[1]); + if (res[0]) |codepoint| { + out[i] = @intCast(codepoint); + } + } + + try testing.expect(std.mem.eql(u8, &out, "Hello, World!")); +} + +test "Well formed utf-8" { + var d: UTF8Decoder = .{}; + var out: [4]u21 = undefined; + var i: usize = 0; + // 4 bytes, 3 bytes, 2 bytes, 1 byte + for ("๐Ÿ˜„โœครA") |byte| { + var consumed = false; + while (!consumed) { + const res = d.next(byte); + consumed = res[1]; + // There are no errors in this sequence, so + // every byte should be consumed first try. + try testing.expect(consumed == true); + if (res[0]) |codepoint| { + out[i] = codepoint; + i += 1; + } + } + } + + try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0x1F604, 0x2724, 0xC1, 0x41 })); +} + +test "Partially invalid utf-8" { + var d: UTF8Decoder = .{}; + var out: [5]u21 = undefined; + var i: usize = 0; + // Illegally terminated sequence, valid sequence, illegal surrogate pair. + for ("\xF0\x9F๐Ÿ˜„\xED\xA0\x80") |byte| { + var consumed = false; + while (!consumed) { + const res = d.next(byte); + consumed = res[1]; + if (res[0]) |codepoint| { + out[i] = codepoint; + i += 1; + } + } + } + + try testing.expect(std.mem.eql(u21, &out, &[_]u21{ 0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD })); +} diff --git a/src/terminal2/apc.zig b/src/terminal2/apc.zig new file mode 100644 index 000000000..6a6b8cc36 --- /dev/null +++ b/src/terminal2/apc.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const kitty_gfx = @import("kitty/graphics.zig"); + +const log = std.log.scoped(.terminal_apc); + +/// APC command handler. This should be hooked into a terminal.Stream handler. +/// The start/feed/end functions are meant to be called from the terminal.Stream +/// apcStart, apcPut, and apcEnd functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + pub fn deinit(self: *Handler) void { + self.state.deinit(); + } + + pub fn start(self: *Handler) void { + self.state.deinit(); + self.state = .{ .identify = {} }; + } + + pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { + switch (self.state) { + .inactive => unreachable, + + // We're ignoring this APC command, likely because we don't + // recognize it so there is no need to store the data in memory. + .ignore => return, + + // We identify the APC command by the first byte. + .identify => { + switch (byte) { + // Kitty graphics protocol + 'G' => self.state = .{ .kitty = kitty_gfx.CommandParser.init(alloc) }, + + // Unknown + else => self.state = .{ .ignore = {} }, + } + }, + + .kitty => |*p| p.feed(byte) catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + self.state = .{ .ignore = {} }; + }, + } + } + + pub fn end(self: *Handler) ?Command { + defer { + self.state.deinit(); + self.state = .{ .inactive = {} }; + } + + return switch (self.state) { + .inactive => unreachable, + .ignore, .identify => null, + .kitty => |*p| kitty: { + const command = p.complete() catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + break :kitty null; + }; + + break :kitty .{ .kitty = command }; + }, + }; + } +}; + +pub const State = union(enum) { + /// We're not in the middle of an APC command yet. + inactive: void, + + /// We got an unrecognized APC sequence or the APC sequence we + /// recognized became invalid. We're just dropping bytes. + ignore: void, + + /// We're waiting to identify the APC sequence. This is done by + /// inspecting the first byte of the sequence. + identify: void, + + /// Kitty graphics protocol + kitty: kitty_gfx.CommandParser, + + pub fn deinit(self: *State) void { + switch (self.*) { + .inactive, .ignore, .identify => {}, + .kitty => |*v| v.deinit(), + } + } +}; + +/// Possible APC commands. +pub const Command = union(enum) { + kitty: kitty_gfx.Command, + + pub fn deinit(self: *Command, alloc: Allocator) void { + switch (self.*) { + .kitty => |*v| v.deinit(alloc), + } + } +}; + +test "unknown APC command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Xabcdef1234") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "garbage Kitty command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Gabcdef1234") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "valid Kitty command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + const input = "Gf=24,s=10,v=20,hello=world"; + for (input) |c| h.feed(alloc, c); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); +} diff --git a/src/terminal2/dcs.zig b/src/terminal2/dcs.zig new file mode 100644 index 000000000..cde00d218 --- /dev/null +++ b/src/terminal2/dcs.zig @@ -0,0 +1,309 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminal = @import("main.zig"); +const DCS = terminal.DCS; + +const log = std.log.scoped(.terminal_dcs); + +/// DCS command handler. This should be hooked into a terminal.Stream handler. +/// The hook/put/unhook functions are meant to be called from the +/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + /// Maximum bytes any DCS command can take. This is to prevent + /// malicious input from causing us to allocate too much memory. + /// This is arbitrarily set to 1MB today, increase if needed. + max_bytes: usize = 1024 * 1024, + + pub fn deinit(self: *Handler) void { + self.discard(); + } + + pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void { + assert(self.state == .inactive); + self.state = if (tryHook(alloc, dcs)) |state_| state: { + if (state_) |state| break :state state else { + log.info("unknown DCS hook: {}", .{dcs}); + break :state .{ .ignore = {} }; + } + } else |err| state: { + log.info( + "error initializing DCS hook, will ignore hook err={}", + .{err}, + ); + break :state .{ .ignore = {} }; + }; + } + + fn tryHook(alloc: Allocator, dcs: DCS) !?State { + return switch (dcs.intermediates.len) { + 1 => switch (dcs.intermediates[0]) { + '+' => switch (dcs.final) { + // XTGETTCAP + // https://github.com/mitchellh/ghostty/issues/517 + 'q' => .{ + .xtgettcap = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice + ), + }, + + else => null, + }, + + '$' => switch (dcs.final) { + // DECRQSS + 'q' => .{ + .decrqss = .{}, + }, + + else => null, + }, + + else => null, + }, + + else => null, + }; + } + + pub fn put(self: *Handler, byte: u8) void { + self.tryPut(byte) catch |err| { + // On error we just discard our state and ignore the rest + log.info("error putting byte into DCS handler err={}", .{err}); + self.discard(); + self.state = .{ .ignore = {} }; + }; + } + + fn tryPut(self: *Handler, byte: u8) !void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| { + if (list.items.len >= self.max_bytes) { + return error.OutOfMemory; + } + + try list.append(byte); + }, + + .decrqss => |*buffer| { + if (buffer.len >= buffer.data.len) { + return error.OutOfMemory; + } + + buffer.data[buffer.len] = byte; + buffer.len += 1; + }, + } + } + + pub fn unhook(self: *Handler) ?Command { + defer self.state = .{ .inactive = {} }; + return switch (self.state) { + .inactive, + .ignore, + => null, + + .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, + + .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { + 0 => .none, + 1 => switch (buffer.data[0]) { + 'm' => .sgr, + 'r' => .decstbm, + 's' => .decslrm, + else => .none, + }, + 2 => switch (buffer.data[0]) { + ' ' => switch (buffer.data[1]) { + 'q' => .decscusr, + else => .none, + }, + else => .none, + }, + else => unreachable, + } }, + }; + } + + fn discard(self: *Handler) void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| list.deinit(), + + .decrqss => {}, + } + + self.state = .{ .inactive = {} }; + } +}; + +pub const Command = union(enum) { + /// XTGETTCAP + xtgettcap: XTGETTCAP, + + /// DECRQSS + decrqss: DECRQSS, + + pub fn deinit(self: Command) void { + switch (self) { + .xtgettcap => |*v| { + v.data.deinit(); + }, + .decrqss => {}, + } + } + + pub const XTGETTCAP = struct { + data: std.ArrayList(u8), + i: usize = 0, + + /// Returns the next terminfo key being requested and null + /// when there are no more keys. The returned value is NOT hex-decoded + /// because we expect to use a comptime lookup table. + pub fn next(self: *XTGETTCAP) ?[]const u8 { + if (self.i >= self.data.items.len) return null; + + var rem = self.data.items[self.i..]; + const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len; + + // Note that if we're at the end, idx + 1 is len + 1 so we're over + // the end but that's okay because our check above is >= so we'll + // never read. + self.i += idx + 1; + + return rem[0..idx]; + } + }; + + /// Supported DECRQSS settings + pub const DECRQSS = enum { + none, + sgr, + decscusr, + decstbm, + decslrm, + }; +}; + +const State = union(enum) { + /// We're not in a DCS state at the moment. + inactive: void, + + /// We're hooked, but its an unknown DCS command or one that went + /// invalid due to some bad input, so we're ignoring the rest. + ignore: void, + + /// XTGETTCAP + xtgettcap: std.ArrayList(u8), + + /// DECRQSS + decrqss: struct { + data: [2]u8 = undefined, + len: u2 = 0, + }, +}; + +test "unknown DCS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .final = 'A' }); + try testing.expect(h.state == .ignore); + try testing.expect(h.unhook() == null); + try testing.expect(h.state == .inactive); +} + +test "XTGETTCAP command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command multiple keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command invalid data" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("who;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("who", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "DECRQSS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('m'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .sgr); +} + +test "DECRQSS invalid command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('z'); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .decrqss); + try testing.expect(cmd.decrqss == .none); + + h.discard(); + + h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + h.put('"'); + h.put(' '); + h.put('q'); + try testing.expect(h.unhook() == null); +} diff --git a/src/terminal2/device_status.zig b/src/terminal2/device_status.zig new file mode 100644 index 000000000..78147ddd4 --- /dev/null +++ b/src/terminal2/device_status.zig @@ -0,0 +1,67 @@ +const std = @import("std"); + +/// An enum(u16) of the available device status requests. +pub const Request = dsr_enum: { + const EnumField = std.builtin.Type.EnumField; + var fields: [entries.len]EnumField = undefined; + for (entries, 0..) |entry, i| { + fields[i] = .{ + .name = entry.name, + .value = @as(Tag.Backing, @bitCast(Tag{ + .value = entry.value, + .question = entry.question, + })), + }; + } + + break :dsr_enum @Type(.{ .Enum = .{ + .tag_type = Tag.Backing, + .fields = &fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +/// The tag type for our enum is a u16 but we use a packed struct +/// in order to pack the question bit into the tag. The "u16" size is +/// chosen somewhat arbitrarily to match the largest expected size +/// we see as a multiple of 8 bits. +pub const Tag = packed struct(u16) { + pub const Backing = @typeInfo(@This()).Struct.backing_integer.?; + value: u15, + question: bool = false, + + test "order" { + const t: Tag = .{ .value = 1 }; + const int: Backing = @bitCast(t); + try std.testing.expectEqual(@as(Backing, 1), int); + } +}; + +pub fn reqFromInt(v: u16, question: bool) ?Request { + inline for (entries) |entry| { + if (entry.value == v and entry.question == question) { + const tag: Tag = .{ .question = question, .value = entry.value }; + const int: Tag.Backing = @bitCast(tag); + return @enumFromInt(int); + } + } + + return null; +} + +/// A single entry of a possible device status request we support. The +/// "question" field determines if it is valid with or without the "?" +/// prefix. +const Entry = struct { + name: [:0]const u8, + value: comptime_int, + question: bool = false, // "?" request +}; + +/// The full list of device status request entries. +const entries: []const Entry = &.{ + .{ .name = "operating_status", .value = 5 }, + .{ .name = "cursor_position", .value = 6 }, + .{ .name = "color_scheme", .value = 996, .question = true }, +}; diff --git a/src/terminal2/main.zig b/src/terminal2/main.zig index 673a82d1b..2d813c02b 100644 --- a/src/terminal2/main.zig +++ b/src/terminal2/main.zig @@ -52,4 +52,5 @@ test { _ = @import("hash_map.zig"); _ = @import("size.zig"); _ = @import("style.zig"); + _ = @import("Selection.zig"); } diff --git a/src/terminal2/osc.zig b/src/terminal2/osc.zig new file mode 100644 index 000000000..a220ea031 --- /dev/null +++ b/src/terminal2/osc.zig @@ -0,0 +1,1274 @@ +//! OSC (Operating System Command) related functions and types. OSC is +//! another set of control sequences for terminal programs that start with +//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings +//! and other irregular formatting so a dedicated parser is created to handle it. +const osc = @This(); + +const std = @import("std"); +const mem = std.mem; +const assert = std.debug.assert; +const Allocator = mem.Allocator; + +const log = std.log.scoped(.osc); + +pub const Command = union(enum) { + /// Set the window title of the terminal + /// + /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 + /// with each code unit further encoded with two hex digets). + /// + /// If title mode 2 is set or the terminal is setup for unconditional + /// utf-8 titles text is interpreted as utf-8. Else text is interpreted + /// as latin1. + change_window_title: []const u8, + + /// Set the icon of the terminal window. The name of the icon is not + /// well defined, so this is currently ignored by Ghostty at the time + /// of writing this. We just parse it so that we don't get parse errors + /// in the log. + change_window_icon: []const u8, + + /// First do a fresh-line. Then start a new command, and enter prompt mode: + /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a + /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed + /// not all shells will send the prompt end code. + prompt_start: struct { + aid: ?[]const u8 = null, + kind: enum { primary, right, continuation } = .primary, + redraw: bool = true, + }, + + /// End of prompt and start of user input, terminated by a OSC "133;C" + /// or another prompt (OSC "133;P"). + prompt_end: void, + + /// The OSC "133;C" command can be used to explicitly end + /// the input area and begin the output area. However, some applications + /// don't provide a convenient way to emit that command. + /// That is why we also specify an implicit way to end the input area + /// at the end of the line. In the case of multiple input lines: If the + /// cursor is on a fresh (empty) line and we see either OSC "133;P" or + /// OSC "133;I" then this is the start of a continuation input line. + /// If we see anything else, it is the start of the output area (or end + /// of command). + end_of_input: void, + + /// End of current command. + /// + /// The exit-code need not be specified if if there are no options, + /// or if the command was cancelled (no OSC "133;C"), such as by typing + /// an interrupt/cancel character (typically ctrl-C) during line-editing. + /// Otherwise, it must be an integer code, where 0 means the command + /// succeeded, and other values indicate failure. In additing to the + /// exit-code there may be an err= option, which non-legacy terminals + /// should give precedence to. The err=_value_ option is more general: + /// an empty string is success, and any non-empty value (which need not + /// be an integer) is an error code. So to indicate success both ways you + /// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter. + end_of_command: struct { + exit_code: ?u8 = null, + // TODO: err option + }, + + /// Set or get clipboard contents. If data is null, then the current + /// clipboard contents are sent to the pty. If data is set, this + /// contents is set on the clipboard. + clipboard_contents: struct { + kind: u8, + data: []const u8, + }, + + /// OSC 7. Reports the current working directory of the shell. This is + /// a moderately flawed escape sequence but one that many major terminals + /// support so we also support it. To understand the flaws, read through + /// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20 + report_pwd: struct { + /// The reported pwd value. This is not checked for validity. It should + /// be a file URL but it is up to the caller to utilize this value. + value: []const u8, + }, + + /// OSC 22. Set the mouse shape. There doesn't seem to be a standard + /// naming scheme for cursors but it looks like terminals such as Foot + /// are moving towards using the W3C CSS cursor names. For OSC parsing, + /// we just parse whatever string is given. + mouse_shape: struct { + value: []const u8, + }, + + /// OSC 4, OSC 10, and OSC 11 color report. + report_color: struct { + /// OSC 4 requests a palette color, OSC 10 requests the foreground + /// color, OSC 11 the background color. + kind: ColorKind, + + /// We must reply with the same string terminator (ST) as used in the + /// request. + terminator: Terminator = .st, + }, + + /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) + set_color: struct { + /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 + /// the background color. + kind: ColorKind, + + /// The color spec as a string + value: []const u8, + }, + + /// Reset a palette color (OSC 104) or the foreground (OSC 110), background + /// (OSC 111), or cursor (OSC 112) color. + reset_color: struct { + kind: ColorKind, + + /// OSC 104 can have parameters indicating which palette colors to + /// reset. + value: []const u8, + }, + + /// Show a desktop notification (OSC 9 or OSC 777) + show_desktop_notification: struct { + title: []const u8, + body: []const u8, + }, + + pub const ColorKind = union(enum) { + palette: u8, + foreground, + background, + cursor, + + pub fn code(self: ColorKind) []const u8 { + return switch (self) { + .palette => "4", + .foreground => "10", + .background => "11", + .cursor => "12", + }; + } + }; +}; + +/// The terminator used to end an OSC command. For OSC commands that demand +/// a response, we try to match the terminator used in the request since that +/// is most likely to be accepted by the calling program. +pub const Terminator = enum { + /// The preferred string terminator is ESC followed by \ + st, + + /// Some applications and terminals use BELL (0x07) as the string terminator. + bel, + + /// Initialize the terminator based on the last byte seen. If the + /// last byte is a BEL then we use BEL, otherwise we just assume ST. + pub fn init(ch: ?u8) Terminator { + return switch (ch orelse return .st) { + 0x07 => .bel, + else => .st, + }; + } + + /// The terminator as a string. This is static memory so it doesn't + /// need to be freed. + pub fn string(self: Terminator) []const u8 { + return switch (self) { + .st => "\x1b\\", + .bel => "\x07", + }; + } +}; + +pub const Parser = struct { + /// Optional allocator used to accept data longer than MAX_BUF. + /// This only applies to some commands (e.g. OSC 52) that can + /// reasonably exceed MAX_BUF. + alloc: ?Allocator = null, + + /// Current state of the parser. + state: State = .empty, + + /// Current command of the parser, this accumulates. + command: Command = undefined, + + /// Buffer that stores the input we see for a single OSC command. + /// Slices in Command are offsets into this buffer. + buf: [MAX_BUF]u8 = undefined, + buf_start: usize = 0, + buf_idx: usize = 0, + buf_dynamic: ?*std.ArrayListUnmanaged(u8) = null, + + /// True when a command is complete/valid to return. + complete: bool = false, + + /// Temporary state that is dependent on the current state. + temp_state: union { + /// Current string parameter being populated + str: *[]const u8, + + /// Current numeric parameter being populated + num: u16, + + /// Temporary state for key/value pairs + key: []const u8, + } = undefined, + + // Maximum length of a single OSC command. This is the full OSC command + // sequence length (excluding ESC ]). This is arbitrary, I couldn't find + // any definitive resource on how long this should be. + const MAX_BUF = 2048; + + pub const State = enum { + empty, + invalid, + + // Command prefixes. We could just accumulate and compare (mem.eql) + // but the state space is small enough that we just build it up this way. + @"0", + @"1", + @"10", + @"11", + @"12", + @"13", + @"133", + @"2", + @"22", + @"4", + @"5", + @"52", + @"7", + @"77", + @"777", + @"9", + + // OSC 10 is used to query or set the current foreground color. + query_fg_color, + + // OSC 11 is used to query or set the current background color. + query_bg_color, + + // OSC 12 is used to query or set the current cursor color. + query_cursor_color, + + // We're in a semantic prompt OSC command but we aren't sure + // what the command is yet, i.e. `133;` + semantic_prompt, + semantic_option_start, + semantic_option_key, + semantic_option_value, + semantic_exit_code_start, + semantic_exit_code, + + // Get/set clipboard states + clipboard_kind, + clipboard_kind_end, + + // Get/set color palette index + color_palette_index, + color_palette_index_end, + + // Reset color palette index + reset_color_palette_index, + + // rxvt extension. Only used for OSC 777 and only the value "notify" is + // supported + rxvt_extension, + + // Title of a desktop notification + notification_title, + + // Expect a string parameter. param_str must be set as well as + // buf_start. + string, + + // A string that can grow beyond MAX_BUF. This uses the allocator. + // If the parser has no allocator then it is treated as if the + // buffer is full. + allocable_string, + }; + + /// This must be called to clean up any allocated memory. + pub fn deinit(self: *Parser) void { + self.reset(); + } + + /// Reset the parser start. + pub fn reset(self: *Parser) void { + self.state = .empty; + self.buf_start = 0; + self.buf_idx = 0; + self.complete = false; + if (self.buf_dynamic) |ptr| { + const alloc = self.alloc.?; + ptr.deinit(alloc); + alloc.destroy(ptr); + self.buf_dynamic = null; + } + } + + /// Consume the next character c and advance the parser state. + pub fn next(self: *Parser, c: u8) void { + // If our buffer is full then we're invalid. + if (self.buf_idx >= self.buf.len) { + self.state = .invalid; + return; + } + + // We store everything in the buffer so we can do a better job + // logging if we get to an invalid command. + self.buf[self.buf_idx] = c; + self.buf_idx += 1; + + // log.warn("state = {} c = {x}", .{ self.state, c }); + + switch (self.state) { + // If we get something during the invalid state, we've + // ruined our entry. + .invalid => self.complete = false, + + .empty => switch (c) { + '0' => self.state = .@"0", + '1' => self.state = .@"1", + '2' => self.state = .@"2", + '4' => self.state = .@"4", + '5' => self.state = .@"5", + '7' => self.state = .@"7", + '9' => self.state = .@"9", + else => self.state = .invalid, + }, + + .@"0" => switch (c) { + ';' => { + self.command = .{ .change_window_title = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_title }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"1" => switch (c) { + ';' => { + self.command = .{ .change_window_icon = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_icon }; + self.buf_start = self.buf_idx; + }, + '0' => self.state = .@"10", + '1' => self.state = .@"11", + '2' => self.state = .@"12", + '3' => self.state = .@"13", + else => self.state = .invalid, + }, + + .@"10" => switch (c) { + ';' => self.state = .query_fg_color, + '4' => { + self.command = .{ .reset_color = .{ + .kind = .{ .palette = 0 }, + .value = "", + } }; + + self.state = .reset_color_palette_index; + self.complete = true; + }, + else => self.state = .invalid, + }, + + .@"11" => switch (c) { + ';' => self.state = .query_bg_color, + '0' => { + self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + '1' => { + self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + '2' => { + self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; + self.complete = true; + self.state = .invalid; + }, + else => self.state = .invalid, + }, + + .@"12" => switch (c) { + ';' => self.state = .query_cursor_color, + else => self.state = .invalid, + }, + + .@"13" => switch (c) { + '3' => self.state = .@"133", + else => self.state = .invalid, + }, + + .@"133" => switch (c) { + ';' => self.state = .semantic_prompt, + else => self.state = .invalid, + }, + + .@"2" => switch (c) { + '2' => self.state = .@"22", + ';' => { + self.command = .{ .change_window_title = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.change_window_title }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"22" => switch (c) { + ';' => { + self.command = .{ .mouse_shape = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.mouse_shape.value }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .@"4" => switch (c) { + ';' => { + self.state = .color_palette_index; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .color_palette_index => switch (c) { + '0'...'9' => {}, + ';' => blk: { + const str = self.buf[self.buf_start .. self.buf_idx - 1]; + if (str.len == 0) { + self.state = .invalid; + break :blk; + } + + if (std.fmt.parseUnsigned(u8, str, 10)) |num| { + self.state = .color_palette_index_end; + self.temp_state = .{ .num = num }; + } else |err| switch (err) { + error.Overflow => self.state = .invalid, + error.InvalidCharacter => unreachable, + } + }, + else => self.state = .invalid, + }, + + .color_palette_index_end => switch (c) { + '?' => { + self.command = .{ .report_color = .{ + .kind = .{ .palette = @intCast(self.temp_state.num) }, + } }; + + self.complete = true; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .{ .palette = @intCast(self.temp_state.num) }, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .reset_color_palette_index => switch (c) { + ';' => { + self.state = .string; + self.temp_state = .{ .str = &self.command.reset_color.value }; + self.buf_start = self.buf_idx; + self.complete = false; + }, + else => { + self.state = .invalid; + self.complete = false; + }, + }, + + .@"5" => switch (c) { + '2' => self.state = .@"52", + else => self.state = .invalid, + }, + + .@"52" => switch (c) { + ';' => { + self.command = .{ .clipboard_contents = undefined }; + self.state = .clipboard_kind; + }, + else => self.state = .invalid, + }, + + .clipboard_kind => switch (c) { + ';' => { + self.command.clipboard_contents.kind = 'c'; + self.temp_state = .{ .str = &self.command.clipboard_contents.data }; + self.buf_start = self.buf_idx; + self.prepAllocableString(); + }, + else => { + self.command.clipboard_contents.kind = c; + self.state = .clipboard_kind_end; + }, + }, + + .clipboard_kind_end => switch (c) { + ';' => { + self.temp_state = .{ .str = &self.command.clipboard_contents.data }; + self.buf_start = self.buf_idx; + self.prepAllocableString(); + }, + else => self.state = .invalid, + }, + + .@"7" => switch (c) { + ';' => { + self.command = .{ .report_pwd = .{ .value = "" } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.report_pwd.value }; + self.buf_start = self.buf_idx; + }, + '7' => self.state = .@"77", + else => self.state = .invalid, + }, + + .@"77" => switch (c) { + '7' => self.state = .@"777", + else => self.state = .invalid, + }, + + .@"777" => switch (c) { + ';' => { + self.state = .rxvt_extension; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .rxvt_extension => switch (c) { + 'a'...'z' => {}, + ';' => { + const ext = self.buf[self.buf_start .. self.buf_idx - 1]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + self.state = .invalid; + return; + } + + self.command = .{ .show_desktop_notification = undefined }; + self.buf_start = self.buf_idx; + self.state = .notification_title; + }, + else => self.state = .invalid, + }, + + .notification_title => switch (c) { + ';' => { + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => {}, + }, + + .@"9" => switch (c) { + ';' => { + self.command = .{ .show_desktop_notification = .{ + .title = "", + .body = undefined, + } }; + + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => self.state = .invalid, + }, + + .query_fg_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .foreground } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .foreground, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .query_bg_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .background } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .background, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .query_cursor_color => switch (c) { + '?' => { + self.command = .{ .report_color = .{ .kind = .cursor } }; + self.complete = true; + self.state = .invalid; + }, + else => { + self.command = .{ .set_color = .{ + .kind = .cursor, + .value = "", + } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.set_color.value }; + self.buf_start = self.buf_idx - 1; + }, + }, + + .semantic_prompt => switch (c) { + 'A' => { + self.state = .semantic_option_start; + self.command = .{ .prompt_start = .{} }; + self.complete = true; + }, + + 'B' => { + self.state = .semantic_option_start; + self.command = .{ .prompt_end = {} }; + self.complete = true; + }, + + 'C' => { + self.state = .semantic_option_start; + self.command = .{ .end_of_input = {} }; + self.complete = true; + }, + + 'D' => { + self.state = .semantic_exit_code_start; + self.command = .{ .end_of_command = .{} }; + self.complete = true; + }, + + else => self.state = .invalid, + }, + + .semantic_option_start => switch (c) { + ';' => { + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .semantic_option_key => switch (c) { + '=' => { + self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; + self.state = .semantic_option_value; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .semantic_option_value => switch (c) { + ';' => { + self.endSemanticOptionValue(); + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .semantic_exit_code_start => switch (c) { + ';' => { + // No longer complete, if ';' shows up we expect some code. + self.complete = false; + self.state = .semantic_exit_code; + self.temp_state = .{ .num = 0 }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .semantic_exit_code => switch (c) { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { + self.complete = true; + + const idx = self.buf_idx - self.buf_start; + if (idx > 0) self.temp_state.num *|= 10; + self.temp_state.num +|= c - '0'; + }, + ';' => { + self.endSemanticExitCode(); + self.state = .semantic_option_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .allocable_string => { + const alloc = self.alloc.?; + const list = self.buf_dynamic.?; + list.append(alloc, c) catch { + self.state = .invalid; + return; + }; + + // Never consume buffer space for allocable strings + self.buf_idx -= 1; + + // We can complete at any time + self.complete = true; + }, + + .string => self.complete = true, + } + } + + fn prepAllocableString(self: *Parser) void { + assert(self.buf_dynamic == null); + + // We need an allocator. If we don't have an allocator, we + // pretend we're just a fixed buffer string and hope we fit! + const alloc = self.alloc orelse { + self.state = .string; + return; + }; + + // Allocate our dynamic buffer + const list = alloc.create(std.ArrayListUnmanaged(u8)) catch { + self.state = .string; + return; + }; + list.* = .{}; + + self.buf_dynamic = list; + self.state = .allocable_string; + } + + fn endSemanticOptionValue(self: *Parser) void { + const value = self.buf[self.buf_start..self.buf_idx]; + + if (mem.eql(u8, self.temp_state.key, "aid")) { + switch (self.command) { + .prompt_start => |*v| v.aid = value, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "redraw")) { + // Kitty supports a "redraw" option for prompt_start. I can't find + // this documented anywhere but can see in the code that this is used + // by shell environments to tell the terminal that the shell will NOT + // redraw the prompt so we should attempt to resize it. + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.redraw = false, + '1' => v.redraw = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid redraw value: {s}", .{value}); + } + }, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "k")) { + // The "k" marks the kind of prompt, or "primary" if we don't know. + // This can be used to distinguish between the first prompt, + // a continuation, etc. + switch (self.command) { + .prompt_start => |*v| if (value.len == 1) { + v.kind = switch (value[0]) { + 'c', 's' => .continuation, + 'r' => .right, + 'i' => .primary, + else => .primary, + }; + }, + else => {}, + } + } else log.info("unknown semantic prompts option: {s}", .{self.temp_state.key}); + } + + fn endSemanticExitCode(self: *Parser) void { + switch (self.command) { + .end_of_command => |*v| v.exit_code = @truncate(self.temp_state.num), + else => {}, + } + } + + fn endString(self: *Parser) void { + self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; + } + + fn endAllocableString(self: *Parser) void { + const list = self.buf_dynamic.?; + self.temp_state.str.* = list.items; + } + + /// End the sequence and return the command, if any. If the return value + /// is null, then no valid command was found. The optional terminator_ch + /// is the final character in the OSC sequence. This is used to determine + /// the response terminator. + pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { + if (!self.complete) { + log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); + return null; + } + + // Other cleanup we may have to do depending on state. + switch (self.state) { + .semantic_exit_code => self.endSemanticExitCode(), + .semantic_option_value => self.endSemanticOptionValue(), + .string => self.endString(), + .allocable_string => self.endAllocableString(), + else => {}, + } + + switch (self.command) { + .report_color => |*c| c.terminator = Terminator.init(terminator_ch), + else => {}, + } + + return self.command; + } +}; + +test "OSC: change_window_title" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('0'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC: change_window_title with 2" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('2'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC: change_window_title with utf8" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('2'); + p.next(';'); + // 'โ€”' EM DASH U+2014 (E2 80 94) + p.next(0xE2); + p.next(0x80); + p.next(0x94); + + p.next(' '); + // 'โ€' HYPHEN U+2010 (E2 80 90) + // Intententionally chosen to conflict with the 0x90 C1 control + p.next(0xE2); + p.next(0x80); + p.next(0x90); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("โ€” โ€", cmd.change_window_title); +} + +test "OSC: change_window_icon" { + const testing = std.testing; + + var p: Parser = .{}; + p.next('1'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?; + try testing.expect(cmd == .change_window_icon); + try testing.expectEqualStrings("ab", cmd.change_window_icon); +} + +test "OSC: prompt_start" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expect(cmd.prompt_start.redraw); +} + +test "OSC: prompt_start with single option" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); +} + +test "OSC: prompt_start with redraw disabled" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC: prompt_start with redraw invalid value" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;redraw=42"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.redraw); + try testing.expect(cmd.prompt_start.kind == .primary); +} + +test "OSC: prompt_start with continuation" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;A;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .continuation); +} + +test "OSC: end_of_command no exit code" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_command); +} + +test "OSC: end_of_command with exit code" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;D;25"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_command); + try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); +} + +test "OSC: prompt_end" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .prompt_end); +} + +test "OSC: end_of_input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .end_of_input); +} + +test "OSC: reset cursor color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "112"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .reset_color); + try testing.expectEqual(cmd.reset_color.kind, .cursor); +} + +test "OSC: get/set clipboard" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: get/set clipboard (optional parameter)" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "52;;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: get/set clipboard with allocator" { + const testing = std.testing; + const alloc = testing.allocator; + + var p: Parser = .{ .alloc = alloc }; + defer p.deinit(); + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); +} + +test "OSC: report pwd" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;file:///tmp/example"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .report_pwd); + try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); +} + +test "OSC: pointer cursor" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "22;pointer"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .mouse_shape); + try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); +} + +test "OSC: report pwd empty" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "7;"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); +} + +test "OSC: longer than buffer" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "a" ** (Parser.MAX_BUF + 2); + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); +} + +test "OSC: report default foreground color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "10;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = ESC followed by \ + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(cmd.report_color.kind, .foreground); + try testing.expectEqual(cmd.report_color.terminator, .st); +} + +test "OSC: set foreground color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "10;rgbi:0.0/0.5/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x07').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(cmd.set_color.kind, .foreground); + try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); +} + +test "OSC: report default background color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "11;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(cmd.report_color.kind, .background); + try testing.expectEqual(cmd.report_color.terminator, .bel); +} + +test "OSC: set background color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "11;rgb:f/ff/ffff"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(cmd.set_color.kind, .background); + try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); +} + +test "OSC: get palette color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .report_color); + try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); + try testing.expectEqual(cmd.report_color.terminator, .st); +} + +test "OSC: set palette color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;17;rgb:aa/bb/cc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .set_color); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); + try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); +} + +test "OSC: show desktop notification" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world"); +} + +test "OSC: show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "777;notify;Title;Body"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); +} + +test "OSC: empty param" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "4;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} diff --git a/src/terminal2/parse_table.zig b/src/terminal2/parse_table.zig new file mode 100644 index 000000000..66c443783 --- /dev/null +++ b/src/terminal2/parse_table.zig @@ -0,0 +1,389 @@ +//! The primary export of this file is "table", which contains a +//! comptime-generated state transition table for VT emulation. +//! +//! This is based on the vt100.net state machine: +//! https://vt100.net/emu/dec_ansi_parser +//! But has some modifications: +//! +//! * csi_param accepts the colon character (':') since the SGR command +//! accepts colon as a valid parameter value. +//! + +const std = @import("std"); +const builtin = @import("builtin"); +const parser = @import("Parser.zig"); +const State = parser.State; +const Action = parser.TransitionAction; + +/// The state transition table. The type is [u8][State]Transition but +/// comptime-generated to be exactly-sized. +pub const table = genTable(); + +/// Table is the type of the state table. This is dynamically (comptime) +/// generated to be exactly sized. +pub const Table = genTableType(false); + +/// OptionalTable is private to this file. We use this to accumulate and +/// detect invalid transitions created. +const OptionalTable = genTableType(true); + +// Transition is the transition to take within the table +pub const Transition = struct { + state: State, + action: Action, +}; + +/// Table is the type of the state transition table. +fn genTableType(comptime optional: bool) type { + const max_u8 = std.math.maxInt(u8); + const stateInfo = @typeInfo(State); + const max_state = stateInfo.Enum.fields.len; + const Elem = if (optional) ?Transition else Transition; + return [max_u8 + 1][max_state]Elem; +} + +/// Function to generate the full state transition table for VT emulation. +fn genTable() Table { + @setEvalBranchQuota(20000); + + // We accumulate using an "optional" table so we can detect duplicates. + var result: OptionalTable = undefined; + for (0..result.len) |i| { + for (0..result[0].len) |j| { + result[i][j] = null; + } + } + + // anywhere transitions + const stateInfo = @typeInfo(State); + inline for (stateInfo.Enum.fields) |field| { + const source: State = @enumFromInt(field.value); + + // anywhere => ground + single(&result, 0x18, source, .ground, .execute); + single(&result, 0x1A, source, .ground, .execute); + range(&result, 0x80, 0x8F, source, .ground, .execute); + range(&result, 0x91, 0x97, source, .ground, .execute); + single(&result, 0x99, source, .ground, .execute); + single(&result, 0x9A, source, .ground, .execute); + single(&result, 0x9C, source, .ground, .none); + + // anywhere => escape + single(&result, 0x1B, source, .escape, .none); + + // anywhere => sos_pm_apc_string + single(&result, 0x98, source, .sos_pm_apc_string, .none); + single(&result, 0x9E, source, .sos_pm_apc_string, .none); + single(&result, 0x9F, source, .sos_pm_apc_string, .none); + + // anywhere => csi_entry + single(&result, 0x9B, source, .csi_entry, .none); + + // anywhere => dcs_entry + single(&result, 0x90, source, .dcs_entry, .none); + + // anywhere => osc_string + single(&result, 0x9D, source, .osc_string, .none); + } + + // ground + { + // events + single(&result, 0x19, .ground, .ground, .execute); + range(&result, 0, 0x17, .ground, .ground, .execute); + range(&result, 0x1C, 0x1F, .ground, .ground, .execute); + range(&result, 0x20, 0x7F, .ground, .ground, .print); + } + + // escape_intermediate + { + const source = State.escape_intermediate; + + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x30, 0x7E, source, .ground, .esc_dispatch); + } + + // sos_pm_apc_string + { + const source = State.sos_pm_apc_string; + + // events + single(&result, 0x19, source, source, .apc_put); + range(&result, 0, 0x17, source, source, .apc_put); + range(&result, 0x1C, 0x1F, source, source, .apc_put); + range(&result, 0x20, 0x7F, source, source, .apc_put); + } + + // escape + { + const source = State.escape; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x30, 0x4F, source, .ground, .esc_dispatch); + range(&result, 0x51, 0x57, source, .ground, .esc_dispatch); + range(&result, 0x60, 0x7E, source, .ground, .esc_dispatch); + single(&result, 0x59, source, .ground, .esc_dispatch); + single(&result, 0x5A, source, .ground, .esc_dispatch); + single(&result, 0x5C, source, .ground, .esc_dispatch); + + // => escape_intermediate + range(&result, 0x20, 0x2F, source, .escape_intermediate, .collect); + + // => sos_pm_apc_string + single(&result, 0x58, source, .sos_pm_apc_string, .none); + single(&result, 0x5E, source, .sos_pm_apc_string, .none); + single(&result, 0x5F, source, .sos_pm_apc_string, .none); + + // => dcs_entry + single(&result, 0x50, source, .dcs_entry, .none); + + // => csi_entry + single(&result, 0x5B, source, .csi_entry, .none); + + // => osc_string + single(&result, 0x5D, source, .osc_string, .none); + } + + // dcs_entry + { + const source = State.dcs_entry; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_intermediate + range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); + + // => dcs_ignore + single(&result, 0x3A, source, .dcs_ignore, .none); + + // => dcs_param + range(&result, 0x30, 0x39, source, .dcs_param, .param); + single(&result, 0x3B, source, .dcs_param, .param); + range(&result, 0x3C, 0x3F, source, .dcs_param, .collect); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_intermediate + { + const source = State.dcs_intermediate; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_ignore + range(&result, 0x30, 0x3F, source, .dcs_ignore, .none); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_ignore + { + const source = State.dcs_ignore; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + } + + // dcs_param + { + const source = State.dcs_param; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x30, 0x39, source, source, .param); + single(&result, 0x3B, source, source, .param); + single(&result, 0x7F, source, source, .ignore); + + // => dcs_ignore + single(&result, 0x3A, source, .dcs_ignore, .none); + range(&result, 0x3C, 0x3F, source, .dcs_ignore, .none); + + // => dcs_intermediate + range(&result, 0x20, 0x2F, source, .dcs_intermediate, .collect); + + // => dcs_passthrough + range(&result, 0x40, 0x7E, source, .dcs_passthrough, .none); + } + + // dcs_passthrough + { + const source = State.dcs_passthrough; + + // events + single(&result, 0x19, source, source, .put); + range(&result, 0, 0x17, source, source, .put); + range(&result, 0x1C, 0x1F, source, source, .put); + range(&result, 0x20, 0x7E, source, source, .put); + single(&result, 0x7F, source, source, .ignore); + } + + // csi_param + { + const source = State.csi_param; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x30, 0x39, source, source, .param); + single(&result, 0x3A, source, source, .param); + single(&result, 0x3B, source, source, .param); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + range(&result, 0x3C, 0x3F, source, .csi_ignore, .none); + + // => csi_intermediate + range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); + } + + // csi_ignore + { + const source = State.csi_ignore; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x3F, source, source, .ignore); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .none); + } + + // csi_intermediate + { + const source = State.csi_intermediate; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + range(&result, 0x20, 0x2F, source, source, .collect); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + range(&result, 0x30, 0x3F, source, .csi_ignore, .none); + } + + // csi_entry + { + const source = State.csi_entry; + + // events + single(&result, 0x19, source, source, .execute); + range(&result, 0, 0x17, source, source, .execute); + range(&result, 0x1C, 0x1F, source, source, .execute); + single(&result, 0x7F, source, source, .ignore); + + // => ground + range(&result, 0x40, 0x7E, source, .ground, .csi_dispatch); + + // => csi_ignore + single(&result, 0x3A, source, .csi_ignore, .none); + + // => csi_intermediate + range(&result, 0x20, 0x2F, source, .csi_intermediate, .collect); + + // => csi_param + range(&result, 0x30, 0x39, source, .csi_param, .param); + single(&result, 0x3B, source, .csi_param, .param); + range(&result, 0x3C, 0x3F, source, .csi_param, .collect); + } + + // osc_string + { + const source = State.osc_string; + + // events + single(&result, 0x19, source, source, .ignore); + range(&result, 0, 0x06, source, source, .ignore); + range(&result, 0x08, 0x17, source, source, .ignore); + range(&result, 0x1C, 0x1F, source, source, .ignore); + range(&result, 0x20, 0xFF, source, source, .osc_put); + + // XTerm accepts either BEL or ST for terminating OSC + // sequences, and when returning information, uses the same + // terminator used in a query. + single(&result, 0x07, source, .ground, .none); + } + + // Create our immutable version + var final: Table = undefined; + for (0..final.len) |i| { + for (0..final[0].len) |j| { + final[i][j] = result[i][j] orelse transition(@enumFromInt(j), .none); + } + } + + return final; +} + +fn single(t: *OptionalTable, c: u8, s0: State, s1: State, a: Action) void { + const s0_int = @intFromEnum(s0); + + // TODO: enable this but it thinks we're in runtime right now + // if (t[c][s0_int]) |existing| { + // @compileLog(c); + // @compileLog(s0); + // @compileLog(s1); + // @compileLog(existing); + // @compileError("transition set multiple times"); + // } + + t[c][s0_int] = transition(s1, a); +} + +fn range(t: *OptionalTable, from: u8, to: u8, s0: State, s1: State, a: Action) void { + var i = from; + while (i <= to) : (i += 1) { + single(t, i, s0, s1, a); + // If 'to' is 0xFF, our next pass will overflow. Return early to prevent + // the loop from executing it's continue expression + if (i == to) break; + } +} + +fn transition(state: State, action: Action) Transition { + return .{ .state = state, .action = action }; +} + +test { + // This forces comptime-evaluation of table, so we're just testing + // that it succeeds in creation. + _ = table; +} diff --git a/src/terminal2/sanitize.zig b/src/terminal2/sanitize.zig new file mode 100644 index 000000000..f492291aa --- /dev/null +++ b/src/terminal2/sanitize.zig @@ -0,0 +1,13 @@ +const std = @import("std"); + +/// Returns true if the data looks safe to paste. +pub fn isSafePaste(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null; +} + +test isSafePaste { + const testing = std.testing; + try testing.expect(isSafePaste("hello")); + try testing.expect(!isSafePaste("hello\n")); + try testing.expect(!isSafePaste("hello\nworld")); +} diff --git a/src/terminal2/stream.zig b/src/terminal2/stream.zig new file mode 100644 index 000000000..fc97d3685 --- /dev/null +++ b/src/terminal2/stream.zig @@ -0,0 +1,2014 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const simd = @import("../simd/main.zig"); +const Parser = @import("Parser.zig"); +const ansi = @import("ansi.zig"); +const charsets = @import("charsets.zig"); +const device_status = @import("device_status.zig"); +const csi = @import("csi.zig"); +const kitty = @import("kitty.zig"); +const modes = @import("modes.zig"); +const osc = @import("osc.zig"); +const sgr = @import("sgr.zig"); +const UTF8Decoder = @import("UTF8Decoder.zig"); +const MouseShape = @import("mouse_shape.zig").MouseShape; + +const log = std.log.scoped(.stream); + +/// Returns a type that can process a stream of tty control characters. +/// This will call various callback functions on type T. Type T only has to +/// implement the callbacks it cares about; any unimplemented callbacks will +/// logged at runtime. +/// +/// To figure out what callbacks exist, search the source for "hasDecl". This +/// isn't ideal but for now that's the best approach. +/// +/// This is implemented this way because we purposely do NOT want dynamic +/// dispatch for performance reasons. The way this is implemented forces +/// comptime resolution for all function calls. +pub fn Stream(comptime Handler: type) type { + return struct { + const Self = @This(); + + // We use T with @hasDecl so it needs to be a struct. Unwrap the + // pointer if we were given one. + const T = switch (@typeInfo(Handler)) { + .Pointer => |p| p.child, + else => Handler, + }; + + handler: Handler, + parser: Parser = .{}, + utf8decoder: UTF8Decoder = .{}, + + pub fn deinit(self: *Self) void { + self.parser.deinit(); + } + + /// Process a string of characters. + pub fn nextSlice(self: *Self, input: []const u8) !void { + // This is the maximum number of codepoints we can decode + // at one time for this function call. This is somewhat arbitrary + // so if someone can demonstrate a better number then we can switch. + var cp_buf: [4096]u32 = undefined; + + // Split the input into chunks that fit into cp_buf. + var i: usize = 0; + while (true) { + const len = @min(cp_buf.len, input.len - i); + try self.nextSliceCapped(input[i .. i + len], &cp_buf); + i += len; + if (i >= input.len) break; + } + } + + fn nextSliceCapped(self: *Self, input: []const u8, cp_buf: []u32) !void { + assert(input.len <= cp_buf.len); + + var offset: usize = 0; + + // If the scalar UTF-8 decoder was in the middle of processing + // a code sequence, we continue until it's not. + while (self.utf8decoder.state != 0) { + if (offset >= input.len) return; + try self.nextUtf8(input[offset]); + offset += 1; + } + if (offset >= input.len) return; + + // If we're not in the ground state then we process until + // we are. This can happen if the last chunk of input put us + // in the middle of a control sequence. + offset += try self.consumeUntilGround(input[offset..]); + if (offset >= input.len) return; + offset += try self.consumeAllEscapes(input[offset..]); + + // If we're in the ground state then we can use SIMD to process + // input until we see an ESC (0x1B), since all other characters + // up to that point are just UTF-8. + while (self.parser.state == .ground and offset < input.len) { + const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); + for (cp_buf[0..res.decoded]) |cp| { + if (cp <= 0xF) { + try self.execute(@intCast(cp)); + } else { + try self.print(@intCast(cp)); + } + } + // Consume the bytes we just processed. + offset += res.consumed; + + if (offset >= input.len) return; + + // If our offset is NOT an escape then we must have a + // partial UTF-8 sequence. In that case, we pass it off + // to the scalar parser. + if (input[offset] != 0x1B) { + const rem = input[offset..]; + for (rem) |c| try self.nextUtf8(c); + return; + } + + // Process control sequences until we run out. + offset += try self.consumeAllEscapes(input[offset..]); + } + } + + /// Parses back-to-back escape sequences until none are left. + /// Returns the number of bytes consumed from the provided input. + /// + /// Expects input to start with 0x1B, use consumeUntilGround first + /// if the stream may be in the middle of an escape sequence. + fn consumeAllEscapes(self: *Self, input: []const u8) !usize { + var offset: usize = 0; + while (input[offset] == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + offset += 1; + offset += try self.consumeUntilGround(input[offset..]); + if (offset >= input.len) return input.len; + } + return offset; + } + + /// Parses escape sequences until the parser reaches the ground state. + /// Returns the number of bytes consumed from the provided input. + fn consumeUntilGround(self: *Self, input: []const u8) !usize { + var offset: usize = 0; + while (self.parser.state != .ground) { + if (offset >= input.len) return input.len; + try self.nextNonUtf8(input[offset]); + offset += 1; + } + return offset; + } + + /// Like nextSlice but takes one byte and is necessarilly a scalar + /// operation that can't use SIMD. Prefer nextSlice if you can and + /// try to get multiple bytes at once. + pub fn next(self: *Self, c: u8) !void { + // The scalar path can be responsible for decoding UTF-8. + if (self.parser.state == .ground and c != 0x1B) { + try self.nextUtf8(c); + return; + } + + try self.nextNonUtf8(c); + } + + /// Process the next byte and print as necessary. + /// + /// This assumes we're in the UTF-8 decoding state. If we may not + /// be in the UTF-8 decoding state call nextSlice or next. + fn nextUtf8(self: *Self, c: u8) !void { + assert(self.parser.state == .ground and c != 0x1B); + + const res = self.utf8decoder.next(c); + const consumed = res[1]; + if (res[0]) |codepoint| { + if (codepoint <= 0xF) { + try self.execute(@intCast(codepoint)); + } else { + try self.print(@intCast(codepoint)); + } + } + if (!consumed) { + const retry = self.utf8decoder.next(c); + // It should be impossible for the decoder + // to not consume the byte twice in a row. + assert(retry[1] == true); + if (retry[0]) |codepoint| { + if (codepoint <= 0xF) { + try self.execute(@intCast(codepoint)); + } else { + try self.print(@intCast(codepoint)); + } + } + } + } + + /// Process the next character and call any callbacks if necessary. + /// + /// This assumes that we're not in the UTF-8 decoding state. If + /// we may be in the UTF-8 decoding state call nextSlice or next. + fn nextNonUtf8(self: *Self, c: u8) !void { + assert(self.parser.state != .ground or c == 0x1B); + + // Fast path for ESC + if (self.parser.state == .ground and c == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return; + } + // Fast path for CSI entry. + if (self.parser.state == .escape and c == '[') { + self.parser.state = .csi_entry; + return; + } + // Fast path for CSI params. + if (self.parser.state == .csi_param) csi_param: { + switch (c) { + // A C0 escape (yes, this is valid): + 0x00...0x0F => try self.execute(c), + // We ignore C0 escapes > 0xF since execute + // doesn't have processing for them anyway: + 0x10...0x17, 0x19, 0x1C...0x1F => {}, + // We don't currently have any handling for + // 0x18 or 0x1A, but they should still move + // the parser state to ground. + 0x18, 0x1A => self.parser.state = .ground, + // A parameter digit: + '0'...'9' => if (self.parser.params_idx < 16) { + self.parser.param_acc *|= 10; + self.parser.param_acc +|= c - '0'; + // The parser's CSI param action uses param_acc_idx + // to decide if there's a final param that needs to + // be consumed or not, but it doesn't matter really + // what it is as long as it's not 0. + self.parser.param_acc_idx |= 1; + }, + // A parameter separator: + ':', ';' => if (self.parser.params_idx < 16) { + self.parser.params[self.parser.params_idx] = self.parser.param_acc; + self.parser.params_idx += 1; + + self.parser.param_acc = 0; + self.parser.param_acc_idx = 0; + + // Keep track of separator state. + const sep: Parser.ParamSepState = @enumFromInt(c); + if (self.parser.params_idx == 1) self.parser.params_sep = sep; + if (self.parser.params_sep != sep) self.parser.params_sep = .mixed; + }, + // Explicitly ignored: + 0x7F => {}, + // Defer to the state machine to + // handle any other characters: + else => break :csi_param, + } + return; + } + + const actions = self.parser.next(c); + for (actions) |action_opt| { + const action = action_opt orelse continue; + + // if (action != .print) { + // log.info("action: {}", .{action}); + // } + + // If this handler handles everything manually then we do nothing + // if it can be processed. + if (@hasDecl(T, "handleManually")) { + const processed = self.handler.handleManually(action) catch |err| err: { + log.warn("error handling action manually err={} action={}", .{ + err, + action, + }); + + break :err false; + }; + + if (processed) continue; + } + + switch (action) { + .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), + .execute => |code| try self.execute(code), + .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), + .esc_dispatch => |esc| try self.escDispatch(esc), + .osc_dispatch => |cmd| try self.oscDispatch(cmd), + .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { + try self.handler.dcsHook(dcs); + } else log.warn("unimplemented DCS hook", .{}), + .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { + try self.handler.dcsPut(code); + } else log.warn("unimplemented DCS put: {x}", .{code}), + .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { + try self.handler.dcsUnhook(); + } else log.warn("unimplemented DCS unhook", .{}), + .apc_start => if (@hasDecl(T, "apcStart")) { + try self.handler.apcStart(); + } else log.warn("unimplemented APC start", .{}), + .apc_put => |code| if (@hasDecl(T, "apcPut")) { + try self.handler.apcPut(code); + } else log.warn("unimplemented APC put: {x}", .{code}), + .apc_end => if (@hasDecl(T, "apcEnd")) { + try self.handler.apcEnd(); + } else log.warn("unimplemented APC end", .{}), + } + } + } + + pub fn print(self: *Self, c: u21) !void { + if (@hasDecl(T, "print")) { + try self.handler.print(c); + } + } + + pub fn execute(self: *Self, c: u8) !void { + switch (@as(ansi.C0, @enumFromInt(c))) { + // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 + .NUL, .SOH, .STX => {}, + + .ENQ => if (@hasDecl(T, "enquiry")) + try self.handler.enquiry() + else + log.warn("unimplemented execute: {x}", .{c}), + + .BEL => if (@hasDecl(T, "bell")) + try self.handler.bell() + else + log.warn("unimplemented execute: {x}", .{c}), + + .BS => if (@hasDecl(T, "backspace")) + try self.handler.backspace() + else + log.warn("unimplemented execute: {x}", .{c}), + + .HT => if (@hasDecl(T, "horizontalTab")) + try self.handler.horizontalTab(1) + else + log.warn("unimplemented execute: {x}", .{c}), + + .LF, .VT, .FF => if (@hasDecl(T, "linefeed")) + try self.handler.linefeed() + else + log.warn("unimplemented execute: {x}", .{c}), + + .CR => if (@hasDecl(T, "carriageReturn")) + try self.handler.carriageReturn() + else + log.warn("unimplemented execute: {x}", .{c}), + + .SO => if (@hasDecl(T, "invokeCharset")) + try self.handler.invokeCharset(.GL, .G1, false) + else + log.warn("unimplemented invokeCharset: {x}", .{c}), + + .SI => if (@hasDecl(T, "invokeCharset")) + try self.handler.invokeCharset(.GL, .G0, false) + else + log.warn("unimplemented invokeCharset: {x}", .{c}), + + else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), + } + } + + fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { + // Handles aliases first + const action = switch (input.final) { + // Alias for set cursor position + 'f' => blk: { + var copy = input; + copy.final = 'H'; + break :blk copy; + }, + + else => input, + }; + + switch (action.final) { + // CUU - Cursor Up + 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor up command: {}", .{action}); + return; + }, + }, + false, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUD - Cursor Down + 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor down command: {}", .{action}); + return; + }, + }, + false, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUF - Cursor Right + 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor right command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUB - Cursor Left + 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor left command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CNL - Cursor Next Line + 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor up command: {}", .{action}); + return; + }, + }, + true, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CPL - Cursor Previous Line + 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid cursor down command: {}", .{action}); + return; + }, + }, + true, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // HPA - Cursor Horizontal Position Absolute + // TODO: test + 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (action.params.len) { + 0 => try self.handler.setCursorCol(1), + 1 => try self.handler.setCursorCol(action.params[0]), + else => log.warn("invalid HPA command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // CUP - Set Cursor Position. + // TODO: test + 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (action.params.len) { + 0 => try self.handler.setCursorPos(1, 1), + 1 => try self.handler.setCursorPos(action.params[0], 1), + 2 => try self.handler.setCursorPos(action.params[0], action.params[1]), + else => log.warn("invalid CUP command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // CHT - Cursor Horizontal Tabulation + 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid horizontal tab command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Erase Display + 'J' => if (@hasDecl(T, "eraseDisplay")) { + const protected_: ?bool = switch (action.intermediates.len) { + 0 => false, + 1 => if (action.intermediates[0] == '?') true else null, + else => null, + }; + + const protected = protected_ orelse { + log.warn("invalid erase display command: {}", .{action}); + return; + }; + + const mode_: ?csi.EraseDisplay = switch (action.params.len) { + 0 => .below, + 1 => if (action.params[0] <= 3) + std.meta.intToEnum(csi.EraseDisplay, action.params[0]) catch null + else + null, + else => null, + }; + + const mode = mode_ orelse { + log.warn("invalid erase display command: {}", .{action}); + return; + }; + + try self.handler.eraseDisplay(mode, protected); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // Erase Line + 'K' => if (@hasDecl(T, "eraseLine")) { + const protected_: ?bool = switch (action.intermediates.len) { + 0 => false, + 1 => if (action.intermediates[0] == '?') true else null, + else => null, + }; + + const protected = protected_ orelse { + log.warn("invalid erase line command: {}", .{action}); + return; + }; + + const mode_: ?csi.EraseLine = switch (action.params.len) { + 0 => .right, + 1 => if (action.params[0] < 3) @enumFromInt(action.params[0]) else null, + else => null, + }; + + const mode = mode_ orelse { + log.warn("invalid erase line command: {}", .{action}); + return; + }; + + try self.handler.eraseLine(mode, protected); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // IL - Insert Lines + // TODO: test + 'L' => if (@hasDecl(T, "insertLines")) switch (action.params.len) { + 0 => try self.handler.insertLines(1), + 1 => try self.handler.insertLines(action.params[0]), + else => log.warn("invalid IL command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // DL - Delete Lines + // TODO: test + 'M' => if (@hasDecl(T, "deleteLines")) switch (action.params.len) { + 0 => try self.handler.deleteLines(1), + 1 => try self.handler.deleteLines(action.params[0]), + else => log.warn("invalid DL command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // Delete Character (DCH) + 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid delete characters command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Scroll Up (SD) + + 'S' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "scrollUp")) try self.handler.scrollUp( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid scroll up command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + else => log.warn( + "ignoring unimplemented CSI S with intermediates: {s}", + .{action.intermediates}, + ), + }, + + // Scroll Down (SD) + 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid scroll down command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Cursor Tabulation Control + 'W' => { + switch (action.params.len) { + 0 => if (action.intermediates.len == 1 and action.intermediates[0] == '?') { + if (@hasDecl(T, "tabReset")) + try self.handler.tabReset() + else + log.warn("unimplemented tab reset callback: {}", .{action}); + }, + + 1 => switch (action.params[0]) { + 0 => if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{action}), + + 2 => if (@hasDecl(T, "tabClear")) + try self.handler.tabClear(.current) + else + log.warn("unimplemented tab clear callback: {}", .{action}), + + 5 => if (@hasDecl(T, "tabClear")) + try self.handler.tabClear(.all) + else + log.warn("unimplemented tab clear callback: {}", .{action}), + + else => {}, + }, + + else => {}, + } + + log.warn("invalid cursor tabulation control: {}", .{action}); + return; + }, + + // Erase Characters (ECH) + 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid erase characters command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // CHT - Cursor Horizontal Tabulation Back + 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid horizontal tab back command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // HPR - Cursor Horizontal Position Relative + 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid HPR command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // Repeat Previous Char (REP) + 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid print repeat command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // c - Device Attributes (DA1) + 'c' => if (@hasDecl(T, "deviceAttributes")) { + const req: ansi.DeviceAttributeReq = switch (action.intermediates.len) { + 0 => ansi.DeviceAttributeReq.primary, + 1 => switch (action.intermediates[0]) { + '>' => ansi.DeviceAttributeReq.secondary, + '=' => ansi.DeviceAttributeReq.tertiary, + else => null, + }, + else => @as(?ansi.DeviceAttributeReq, null), + } orelse { + log.warn("invalid device attributes command: {}", .{action}); + return; + }; + + try self.handler.deviceAttributes(req, action.params); + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // VPA - Cursor Vertical Position Absolute + 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid VPA command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // VPR - Cursor Vertical Position Relative + 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( + switch (action.params.len) { + 0 => 1, + 1 => action.params[0], + else => { + log.warn("invalid VPR command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // TBC - Tab Clear + // TODO: test + 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( + switch (action.params.len) { + 1 => @enumFromInt(action.params[0]), + else => { + log.warn("invalid tab clear command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}), + + // SM - Set Mode + 'h' => if (@hasDecl(T, "setMode")) mode: { + const ansi_mode = ansi: { + if (action.intermediates.len == 0) break :ansi true; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :ansi false; + + log.warn("invalid set mode command: {}", .{action}); + break :mode; + }; + + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { + try self.handler.setMode(mode, true); + } else { + log.warn("unimplemented mode: {}", .{mode_int}); + } + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // RM - Reset Mode + 'l' => if (@hasDecl(T, "setMode")) mode: { + const ansi_mode = ansi: { + if (action.intermediates.len == 0) break :ansi true; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :ansi false; + + log.warn("invalid set mode command: {}", .{action}); + break :mode; + }; + + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { + try self.handler.setMode(mode, false); + } else { + log.warn("unimplemented mode: {}", .{mode_int}); + } + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + // SGR - Select Graphic Rendition + 'm' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "setAttribute")) { + // log.info("parse SGR params={any}", .{action.params}); + var p: sgr.Parser = .{ .params = action.params, .colon = action.sep == .colon }; + while (p.next()) |attr| { + // log.info("SGR attribute: {}", .{attr}); + try self.handler.setAttribute(attr); + } + } else log.warn("unimplemented CSI callback: {}", .{action}), + + 1 => switch (action.intermediates[0]) { + '>' => if (@hasDecl(T, "setModifyKeyFormat")) blk: { + if (action.params.len == 0) { + // Reset + try self.handler.setModifyKeyFormat(.{ .legacy = {} }); + break :blk; + } + + var format: ansi.ModifyKeyFormat = switch (action.params[0]) { + 0 => .{ .legacy = {} }, + 1 => .{ .cursor_keys = {} }, + 2 => .{ .function_keys = {} }, + 4 => .{ .other_keys = .none }, + else => { + log.warn("invalid setModifyKeyFormat: {}", .{action}); + break :blk; + }, + }; + + if (action.params.len > 2) { + log.warn("invalid setModifyKeyFormat: {}", .{action}); + break :blk; + } + + if (action.params.len == 2) { + switch (format) { + // We don't support any of the subparams yet for these. + .legacy => {}, + .cursor_keys => {}, + .function_keys => {}, + + // We only support the numeric form. + .other_keys => |*v| switch (action.params[1]) { + 2 => v.* = .numeric, + else => v.* = .none, + }, + } + } + + try self.handler.setModifyKeyFormat(format); + } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), + + else => log.warn( + "unknown CSI m with intermediate: {}", + .{action.intermediates[0]}, + ), + }, + + else => { + // Nothing, but I wanted a place to put this comment: + // there are others forms of CSI m that have intermediates. + // `vim --clean` uses `CSI ? 4 m` and I don't know what + // that means. And there is also `CSI > m` which is used + // to control modifier key reporting formats that we don't + // support yet. + log.warn( + "ignoring unimplemented CSI m with intermediates: {s}", + .{action.intermediates}, + ); + }, + }, + + // TODO: test + 'n' => { + // Handle deviceStatusReport first + if (action.intermediates.len == 0 or + action.intermediates[0] == '?') + { + if (!@hasDecl(T, "deviceStatusReport")) { + log.warn("unimplemented CSI callback: {}", .{action}); + return; + } + + if (action.params.len != 1) { + log.warn("invalid device status report command: {}", .{action}); + return; + } + + const question = question: { + if (action.intermediates.len == 0) break :question false; + if (action.intermediates.len == 1 and + action.intermediates[0] == '?') break :question true; + + log.warn("invalid set mode command: {}", .{action}); + return; + }; + + const req = device_status.reqFromInt(action.params[0], question) orelse { + log.warn("invalid device status report command: {}", .{action}); + return; + }; + + try self.handler.deviceStatusReport(req); + return; + } + + // Handle other forms of CSI n + switch (action.intermediates.len) { + 0 => unreachable, // handled above + + 1 => switch (action.intermediates[0]) { + '>' => if (@hasDecl(T, "setModifyKeyFormat")) { + // This isn't strictly correct. CSI > n has parameters that + // control what exactly is being disabled. However, we + // only support reverting back to modify other keys in + // numeric except format. + try self.handler.setModifyKeyFormat(.{ .other_keys = .numeric_except }); + } else log.warn("unimplemented setModifyKeyFormat: {}", .{action}), + + else => log.warn( + "unknown CSI n with intermediate: {}", + .{action.intermediates[0]}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI n with intermediates: {s}", + .{action.intermediates}, + ), + } + }, + + // DECRQM - Request Mode + 'p' => switch (action.intermediates.len) { + 2 => decrqm: { + const ansi_mode = ansi: { + switch (action.intermediates.len) { + 1 => if (action.intermediates[0] == '$') break :ansi true, + 2 => if (action.intermediates[0] == '?' and + action.intermediates[1] == '$') break :ansi false, + else => {}, + } + + log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ); + break :decrqm; + }; + + if (action.params.len != 1) { + log.warn("invalid DECRQM command: {}", .{action}); + break :decrqm; + } + + if (@hasDecl(T, "requestMode")) { + try self.handler.requestMode(action.params[0], ansi_mode); + } else log.warn("unimplemented DECRQM callback: {}", .{action}); + }, + + else => log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ), + }, + + 'q' => switch (action.intermediates.len) { + 1 => switch (action.intermediates[0]) { + // DECSCUSR - Select Cursor Style + // TODO: test + ' ' => { + if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( + switch (action.params.len) { + 0 => ansi.CursorStyle.default, + 1 => @enumFromInt(action.params[0]), + else => { + log.warn("invalid set curor style command: {}", .{action}); + return; + }, + }, + ) else log.warn("unimplemented CSI callback: {}", .{action}); + }, + + // DECSCA + '"' => { + if (@hasDecl(T, "setProtectedMode")) { + const mode_: ?ansi.ProtectedMode = switch (action.params.len) { + else => null, + 0 => .off, + 1 => switch (action.params[0]) { + 0, 2 => .off, + 1 => .dec, + else => null, + }, + }; + + const mode = mode_ orelse { + log.warn("invalid set protected mode command: {}", .{action}); + return; + }; + + try self.handler.setProtectedMode(mode); + } else log.warn("unimplemented CSI callback: {}", .{action}); + }, + + // XTVERSION + '>' => { + if (@hasDecl(T, "reportXtversion")) try self.handler.reportXtversion(); + }, + else => { + log.warn( + "ignoring unimplemented CSI q with intermediates: {s}", + .{action.intermediates}, + ); + }, + }, + + else => log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ), + }, + + 'r' => switch (action.intermediates.len) { + // DECSTBM - Set Top and Bottom Margins + 0 => if (@hasDecl(T, "setTopAndBottomMargin")) { + switch (action.params.len) { + 0 => try self.handler.setTopAndBottomMargin(0, 0), + 1 => try self.handler.setTopAndBottomMargin(action.params[0], 0), + 2 => try self.handler.setTopAndBottomMargin(action.params[0], action.params[1]), + else => log.warn("invalid DECSTBM command: {}", .{action}), + } + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + 1 => switch (action.intermediates[0]) { + // Restore Mode + '?' => if (@hasDecl(T, "restoreMode")) { + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.restoreMode(mode); + } else { + log.warn( + "unimplemented restore mode: {}", + .{mode_int}, + ); + } + } + }, + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI s with intermediates: {s}", + .{action}, + ), + }, + + 's' => switch (action.intermediates.len) { + // DECSLRM + 0 => if (@hasDecl(T, "setLeftAndRightMargin")) { + switch (action.params.len) { + // CSI S is ambiguous with zero params so we defer + // to our handler to do the proper logic. If mode 69 + // is set, then we should invoke DECSLRM, otherwise + // we should invoke SC. + 0 => try self.handler.setLeftAndRightMarginAmbiguous(), + 1 => try self.handler.setLeftAndRightMargin(action.params[0], 0), + 2 => try self.handler.setLeftAndRightMargin(action.params[0], action.params[1]), + else => log.warn("invalid DECSLRM command: {}", .{action}), + } + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + 1 => switch (action.intermediates[0]) { + '?' => if (@hasDecl(T, "saveMode")) { + for (action.params) |mode_int| { + if (modes.modeFromInt(mode_int, false)) |mode| { + try self.handler.saveMode(mode); + } else { + log.warn( + "unimplemented save mode: {}", + .{mode_int}, + ); + } + } + }, + + // XTSHIFTESCAPE + '>' => if (@hasDecl(T, "setMouseShiftCapture")) capture: { + const capture = switch (action.params.len) { + 0 => false, + 1 => switch (action.params[0]) { + 0 => false, + 1 => true, + else => { + log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); + break :capture; + }, + }, + else => { + log.warn("invalid XTSHIFTESCAPE command: {}", .{action}); + break :capture; + }, + }; + + try self.handler.setMouseShiftCapture(capture); + } else log.warn( + "unimplemented CSI callback: {}", + .{action}, + ), + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI s with intermediates: {s}", + .{action}, + ), + }, + + 'u' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "restoreCursor")) + try self.handler.restoreCursor() + else + log.warn("unimplemented CSI callback: {}", .{action}), + + // Kitty keyboard protocol + 1 => switch (action.intermediates[0]) { + '?' => if (@hasDecl(T, "queryKittyKeyboard")) { + try self.handler.queryKittyKeyboard(); + }, + + '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { + const flags: u5 = if (action.params.len == 1) + std.math.cast(u5, action.params[0]) orelse { + log.warn("invalid pushKittyKeyboard command: {}", .{action}); + break :push; + } + else + 0; + + try self.handler.pushKittyKeyboard(@bitCast(flags)); + }, + + '<' => if (@hasDecl(T, "popKittyKeyboard")) { + const number: u16 = if (action.params.len == 1) + action.params[0] + else + 1; + + try self.handler.popKittyKeyboard(number); + }, + + '=' => if (@hasDecl(T, "setKittyKeyboard")) set: { + const flags: u5 = if (action.params.len >= 1) + std.math.cast(u5, action.params[0]) orelse { + log.warn("invalid setKittyKeyboard command: {}", .{action}); + break :set; + } + else + 0; + + const number: u16 = if (action.params.len >= 2) + action.params[1] + else + 1; + + const mode: kitty.KeySetMode = switch (number) { + 0 => .set, + 1 => .@"or", + 2 => .not, + else => { + log.warn("invalid setKittyKeyboard command: {}", .{action}); + break :set; + }, + }; + + try self.handler.setKittyKeyboard( + mode, + @bitCast(flags), + ); + }, + + else => log.warn( + "unknown CSI s with intermediate: {}", + .{action}, + ), + }, + + else => log.warn( + "ignoring unimplemented CSI u: {}", + .{action}, + ), + }, + + // ICH - Insert Blanks + '@' => switch (action.intermediates.len) { + 0 => if (@hasDecl(T, "insertBlanks")) switch (action.params.len) { + 0 => try self.handler.insertBlanks(1), + 1 => try self.handler.insertBlanks(action.params[0]), + else => log.warn("invalid ICH command: {}", .{action}), + } else log.warn("unimplemented CSI callback: {}", .{action}), + + else => log.warn( + "ignoring unimplemented CSI @: {}", + .{action}, + ), + }, + + // DECSASD - Select Active Status Display + '}' => { + const success = decsasd: { + // Verify we're getting a DECSASD command + if (action.intermediates.len != 1 or action.intermediates[0] != '$') + break :decsasd false; + if (action.params.len != 1) + break :decsasd false; + if (!@hasDecl(T, "setActiveStatusDisplay")) + break :decsasd false; + + try self.handler.setActiveStatusDisplay(@enumFromInt(action.params[0])); + break :decsasd true; + }; + + if (!success) log.warn("unimplemented CSI callback: {}", .{action}); + }, + + else => if (@hasDecl(T, "csiUnimplemented")) + try self.handler.csiUnimplemented(action) + else + log.warn("unimplemented CSI action: {}", .{action}), + } + } + + fn oscDispatch(self: *Self, cmd: osc.Command) !void { + switch (cmd) { + .change_window_title => |title| { + if (@hasDecl(T, "changeWindowTitle")) { + if (!std.unicode.utf8ValidateSlice(title)) { + log.warn("change title request: invalid utf-8, ignoring request", .{}); + return; + } + + try self.handler.changeWindowTitle(title); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .change_window_icon => |icon| { + log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); + }, + + .clipboard_contents => |clip| { + if (@hasDecl(T, "clipboardContents")) { + try self.handler.clipboardContents(clip.kind, clip.data); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .prompt_start => |v| { + if (@hasDecl(T, "promptStart")) { + switch (v.kind) { + .primary, .right => try self.handler.promptStart(v.aid, v.redraw), + .continuation => try self.handler.promptContinuation(v.aid), + } + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .prompt_end => { + if (@hasDecl(T, "promptEnd")) { + try self.handler.promptEnd(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .end_of_input => { + if (@hasDecl(T, "endOfInput")) { + try self.handler.endOfInput(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .end_of_command => |end| { + if (@hasDecl(T, "endOfCommand")) { + try self.handler.endOfCommand(end.exit_code); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .report_pwd => |v| { + if (@hasDecl(T, "reportPwd")) { + try self.handler.reportPwd(v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .mouse_shape => |v| { + if (@hasDecl(T, "setMouseShape")) { + const shape = MouseShape.fromString(v.value) orelse { + log.warn("unknown cursor shape: {s}", .{v.value}); + return; + }; + + try self.handler.setMouseShape(shape); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .report_color => |v| { + if (@hasDecl(T, "reportColor")) { + try self.handler.reportColor(v.kind, v.terminator); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .set_color => |v| { + if (@hasDecl(T, "setColor")) { + try self.handler.setColor(v.kind, v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .reset_color => |v| { + if (@hasDecl(T, "resetColor")) { + try self.handler.resetColor(v.kind, v.value); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .show_desktop_notification => |v| { + if (@hasDecl(T, "showDesktopNotification")) { + try self.handler.showDesktopNotification(v.title, v.body); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + } + + // Fall through for when we don't have a handler. + if (@hasDecl(T, "oscUnimplemented")) { + try self.handler.oscUnimplemented(cmd); + } else { + log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); + } + } + + fn configureCharset( + self: *Self, + intermediates: []const u8, + set: charsets.Charset, + ) !void { + if (intermediates.len != 1) { + log.warn("invalid charset intermediate: {any}", .{intermediates}); + return; + } + + const slot: charsets.Slots = switch (intermediates[0]) { + // TODO: support slots '-', '.', '/' + + '(' => .G0, + ')' => .G1, + '*' => .G2, + '+' => .G3, + else => { + log.warn("invalid charset intermediate: {any}", .{intermediates}); + return; + }, + }; + + if (@hasDecl(T, "configureCharset")) { + try self.handler.configureCharset(slot, set); + return; + } + + log.warn("unimplemented configureCharset callback slot={} set={}", .{ + slot, + set, + }); + } + + fn escDispatch( + self: *Self, + action: Parser.Action.ESC, + ) !void { + switch (action.final) { + // Charsets + 'B' => try self.configureCharset(action.intermediates, .ascii), + 'A' => try self.configureCharset(action.intermediates, .british), + '0' => try self.configureCharset(action.intermediates, .dec_special), + + // DECSC - Save Cursor + '7' => if (@hasDecl(T, "saveCursor")) switch (action.intermediates.len) { + 0 => try self.handler.saveCursor(), + else => { + log.warn("invalid command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + '8' => blk: { + switch (action.intermediates.len) { + // DECRC - Restore Cursor + 0 => if (@hasDecl(T, "restoreCursor")) { + try self.handler.restoreCursor(); + break :blk {}; + } else log.warn("unimplemented restore cursor callback: {}", .{action}), + + 1 => switch (action.intermediates[0]) { + // DECALN - Fill Screen with E + '#' => if (@hasDecl(T, "decaln")) { + try self.handler.decaln(); + break :blk {}; + } else log.warn("unimplemented ESC callback: {}", .{action}), + + else => {}, + }, + + else => {}, // fall through + } + + log.warn("unimplemented ESC action: {}", .{action}); + }, + + // IND - Index + 'D' => if (@hasDecl(T, "index")) switch (action.intermediates.len) { + 0 => try self.handler.index(), + else => { + log.warn("invalid index command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // NEL - Next Line + 'E' => if (@hasDecl(T, "nextLine")) switch (action.intermediates.len) { + 0 => try self.handler.nextLine(), + else => { + log.warn("invalid next line command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // HTS - Horizontal Tab Set + 'H' => if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{action}), + + // RI - Reverse Index + 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { + 0 => try self.handler.reverseIndex(), + else => { + log.warn("invalid reverse index command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // SS2 - Single Shift 2 + 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G2, true), + else => { + log.warn("invalid single shift 2 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // SS3 - Single Shift 3 + 'O' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G3, true), + else => { + log.warn("invalid single shift 3 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // DECID + 'Z' => if (@hasDecl(T, "deviceAttributes")) { + try self.handler.deviceAttributes(.primary, &.{}); + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // RIS - Full Reset + 'c' => if (@hasDecl(T, "fullReset")) switch (action.intermediates.len) { + 0 => try self.handler.fullReset(), + else => { + log.warn("invalid full reset command: {}", .{action}); + return; + }, + } else log.warn("unimplemented ESC callback: {}", .{action}), + + // LS2 - Locking Shift 2 + 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G2, false), + else => { + log.warn("invalid single shift 2 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS3 - Locking Shift 3 + 'o' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GL, .G3, false), + else => { + log.warn("invalid single shift 3 command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS1R - Locking Shift 1 Right + '~' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G1, false), + else => { + log.warn("invalid locking shift 1 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS2R - Locking Shift 2 Right + '}' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G2, false), + else => { + log.warn("invalid locking shift 2 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // LS3R - Locking Shift 3 Right + '|' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { + 0 => try self.handler.invokeCharset(.GR, .G3, false), + else => { + log.warn("invalid locking shift 3 right command: {}", .{action}); + return; + }, + } else log.warn("unimplemented invokeCharset: {}", .{action}), + + // Set application keypad mode + '=' => if (@hasDecl(T, "setMode")) { + try self.handler.setMode(.keypad_keys, true); + } else log.warn("unimplemented setMode: {}", .{action}), + + // Reset application keypad mode + '>' => if (@hasDecl(T, "setMode")) { + try self.handler.setMode(.keypad_keys, false); + } else log.warn("unimplemented setMode: {}", .{action}), + + else => if (@hasDecl(T, "escUnimplemented")) + try self.handler.escUnimplemented(action) + else + log.warn("unimplemented ESC action: {}", .{action}), + + // Sets ST (string terminator). We don't have to do anything + // because our parser always accepts ST. + '\\' => {}, + } + } + }; +} + +test "stream: print" { + const H = struct { + c: ?u21 = 0, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.next('x'); + try testing.expectEqual(@as(u21, 'x'), s.handler.c.?); +} + +test "simd: print invalid utf-8" { + const H = struct { + c: ?u21 = 0, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice(&.{0xFF}); + try testing.expectEqual(@as(u21, 0xFFFD), s.handler.c.?); +} + +test "simd: complete incomplete utf-8" { + const H = struct { + c: ?u21 = null, + + pub fn print(self: *@This(), c: u21) !void { + self.c = c; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice(&.{0xE0}); // 3 byte + try testing.expect(s.handler.c == null); + try s.nextSlice(&.{0xA0}); // still incomplete + try testing.expect(s.handler.c == null); + try s.nextSlice(&.{0x80}); + try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); +} + +test "stream: cursor right (CUF)" { + const H = struct { + amount: u16 = 0, + + pub fn setCursorRight(self: *@This(), v: u16) !void { + self.amount = v; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[C"); + try testing.expectEqual(@as(u16, 1), s.handler.amount); + + try s.nextSlice("\x1B[5C"); + try testing.expectEqual(@as(u16, 5), s.handler.amount); + + s.handler.amount = 0; + try s.nextSlice("\x1B[5;4C"); + try testing.expectEqual(@as(u16, 0), s.handler.amount); +} + +test "stream: dec set mode (SM) and reset mode (RM)" { + const H = struct { + mode: modes.Mode = @as(modes.Mode, @enumFromInt(1)), + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = @as(modes.Mode, @enumFromInt(1)); + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[?6h"); + try testing.expectEqual(@as(modes.Mode, .origin), s.handler.mode); + + try s.nextSlice("\x1B[?6l"); + try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); +} + +test "stream: ansi set mode (SM) and reset mode (RM)" { + const H = struct { + mode: ?modes.Mode = null, + + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = null; + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[4h"); + try testing.expectEqual(@as(modes.Mode, .insert), s.handler.mode.?); + + try s.nextSlice("\x1B[4l"); + try testing.expect(s.handler.mode == null); +} + +test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { + const H = struct { + mode: ?modes.Mode = null, + + pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { + self.mode = null; + if (v) self.mode = mode; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[6h"); + try testing.expect(s.handler.mode == null); + + try s.nextSlice("\x1B[6l"); + try testing.expect(s.handler.mode == null); +} + +test "stream: restore mode" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn setTopAndBottomMargin(self: *Self, t: u16, b: u16) !void { + _ = t; + _ = b; + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[?42r") |c| try s.next(c); + try testing.expect(!s.handler.called); +} + +test "stream: pop kitty keyboard with no params defaults to 1" { + const H = struct { + const Self = @This(); + n: u16 = 0, + + pub fn popKittyKeyboard(self: *Self, n: u16) !void { + self.n = n; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[2s"); + try testing.expect(s.handler.escape == null); + + try s.nextSlice("\x1B[>s"); + try testing.expect(s.handler.escape.? == false); + + try s.nextSlice("\x1B[>0s"); + try testing.expect(s.handler.escape.? == false); + + try s.nextSlice("\x1B[>1s"); + try testing.expect(s.handler.escape.? == true); +} + +test "stream: change window title with invalid utf-8" { + const H = struct { + seen: bool = false, + + pub fn changeWindowTitle(self: *@This(), title: []const u8) !void { + _ = title; + + self.seen = true; + } + }; + + { + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1b]2;abc\x1b\\"); + try testing.expect(s.handler.seen); + } + + { + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1b]2;abc\xc0\x1b\\"); + try testing.expect(!s.handler.seen); + } +} + +test "stream: insert characters" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn insertBlanks(self: *Self, v: u16) !void { + _ = v; + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[42@") |c| try s.next(c); + try testing.expect(s.handler.called); + + s.handler.called = false; + for ("\x1B[?42@") |c| try s.next(c); + try testing.expect(!s.handler.called); +} + +test "stream: SCOSC" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn setLeftAndRightMargin(self: *Self, left: u16, right: u16) !void { + _ = self; + _ = left; + _ = right; + @panic("bad"); + } + + pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[s") |c| try s.next(c); + try testing.expect(s.handler.called); +} + +test "stream: SCORC" { + const H = struct { + const Self = @This(); + called: bool = false, + + pub fn restoreCursor(self: *Self) !void { + self.called = true; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + for ("\x1B[u") |c| try s.next(c); + try testing.expect(s.handler.called); +} + +test "stream: too many csi params" { + const H = struct { + pub fn setCursorRight(self: *@This(), v: u16) !void { + _ = v; + _ = self; + unreachable; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1;1C"); +} + +test "stream: csi param too long" { + const H = struct { + pub fn setCursorRight(self: *@This(), v: u16) !void { + _ = v; + _ = self; + } + }; + + var s: Stream(H) = .{ .handler = .{} }; + try s.nextSlice("\x1B[1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111C"); +}