diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index cee846375..26ddc1237 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -7,6 +7,7 @@ const Parser = @This(); const std = @import("std"); const testing = std.testing; const table = @import("parse_table.zig").table; +const osc = @import("osc.zig"); const log = std.log.scoped(.parser); @@ -66,6 +67,9 @@ pub const Action = union(enum) { /// Execute the ESC command. esc_dispatch: ESC, + /// Execute the OSC command. + osc_dispatch: osc.Command, + pub const CSI = struct { intermediates: []u8, params: []u16, @@ -95,6 +99,9 @@ params_idx: u8 = 0, param_acc: u16 = 0, param_acc_idx: u8 = 0, +/// Parser for OSC sequences +osc_parser: osc.Parser = .{}, + pub fn init() Parser { return .{}; } @@ -126,20 +133,28 @@ pub fn next(self: *Parser, c: u8) [3]?Action { // 2. transition action // 3. entry action to new state return [3]?Action{ - switch (self.state) { - .osc_string => @panic("TODO"), // TODO: osc_end + // Exit depends on current state + if (self.state == next_state) null else switch (self.state) { + .osc_string => if (self.osc_parser.end()) |cmd| + Action{ .osc_dispatch = cmd } + else + null, .dcs_passthrough => @panic("TODO"), // TODO: unhook else => null, }, self.doAction(action, c), - switch (next_state) { + // 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 => @panic("TODO"), // TODO: osc_start + .osc_string => osc_string: { + self.osc_parser.reset(); + break :osc_string null; + }, .dcs_passthrough => @panic("TODO"), // TODO: hook else => null, }, @@ -147,7 +162,6 @@ pub fn next(self: *Parser, c: u8) [3]?Action { } fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { - _ = self; return switch (action) { .none, .ignore => null, .print => Action{ .print = c }, @@ -191,6 +205,10 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // 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: { // Finalize parameters if we have one if (self.param_acc_idx > 0) { @@ -309,3 +327,25 @@ test "csi: ESC [ 1 ; 4 H" { try testing.expectEqual(@as(u16, 4), d.params[1]); } } + +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); + } +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 1e1286f44..f19fec862 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -137,6 +137,7 @@ pub fn appendChar(self: *Terminal, alloc: Allocator, c: u8) !void { .execute => |code| try self.execute(alloc, code), .csi_dispatch => |csi| try self.csiDispatch(alloc, csi), .esc_dispatch => |esc| try self.escDispatch(alloc, esc), + .osc_dispatch => |cmd| log.warn("unhandled OSC: {}", .{cmd}), } } } @@ -626,6 +627,7 @@ fn getOrPutCell(self: *Terminal, alloc: Allocator, x: usize, y: usize) !*Cell { } test { + _ = @import("osc.zig"); _ = Parser; _ = Tabstops; } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig new file mode 100644 index 000000000..826d38a92 --- /dev/null +++ b/src/terminal/osc.zig @@ -0,0 +1,119 @@ +//! 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 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, +}; + +pub const Parser = struct { + state: State = .empty, + command: Command = undefined, + param_str: ?*[]const u8 = null, + buf: [MAX_BUF]u8 = undefined, + buf_start: usize = 0, + buf_idx: usize = 0, + complete: bool = false, + + // 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, + @"0", + string, + }; + + /// Reset the parser start. + pub fn reset(self: *Parser) void { + self.state = .empty; + self.param_str = null; + self.buf_start = 0; + self.buf_idx = 0; + self.complete = false; + } + + /// Consume the next character c and advance the parser state. + pub fn next(self: *Parser, c: u8) void { + // 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.info("state = {} c = {x}", .{ self.state, c }); + + switch (self.state) { + // Ignore, we're in some invalid state and we can't possibly + // do anything reasonable. + .invalid => {}, + + .empty => switch (c) { + '0' => self.state = .@"0", + else => self.state = .invalid, + }, + + .@"0" => switch (c) { + ';' => { + self.command = .{ .change_window_title = undefined }; + + self.state = .string; + self.param_str = &self.command.change_window_title; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .string => { + // Complete once we receive one character since we have + // at least SOME value for the expected string value. + self.complete = true; + }, + } + } + + /// End the sequence and return the command, if any. If the return value + /// is null, then no valid command was found. + pub fn end(self: Parser) ?Command { + if (!self.complete) { + log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); + return null; + } + + // If we have an expected string parameter, fill it in. + if (self.param_str) |param_str| { + param_str.* = self.buf[self.buf_start..self.buf_idx]; + } + + 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().?; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} diff --git a/src/terminal/parse_table.zig b/src/terminal/parse_table.zig index 2311df44e..e353ba524 100644 --- a/src/terminal/parse_table.zig +++ b/src/terminal/parse_table.zig @@ -319,11 +319,13 @@ fn genTable() Table { // events single(&result, 0x19, source, source, .ignore); - range(&result, 0, 0x17, 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, 0x7F, source, source, .osc_put); // => ground + single(&result, 0x07, source, .ground, .none); single(&result, 0x9C, source, .ground, .none); }