diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 0b7d79c10..e1fb7b37d 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -469,6 +469,12 @@ pub fn render( state.mutex.lock(); defer state.mutex.unlock(); + // If we're in a synchronized output state, we pause all rendering. + if (state.terminal.modes.get(.synchronized_output)) { + log.debug("synchronized output started, skipping render", .{}); + return; + } + self.cursor_visible = visible: { // If the cursor is explicitly not visible in the state, // then it is not visible. diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index d7b5d6b5c..a05e4c287 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -711,6 +711,12 @@ pub fn render( state.mutex.lock(); defer state.mutex.unlock(); + // If we're in a synchronized output state, we pause all rendering. + if (state.terminal.modes.get(.synchronized_output)) { + log.debug("synchronized output started, skipping render", .{}); + return; + } + self.cursor_visible = visible: { // If the cursor is explicitly not visible in the state, // then it is not visible. diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 90aaefcd6..ee65a165f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -694,6 +694,33 @@ test "csi: colon for non-m final" { 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 "osc: change window title" { var p = init(); _ = p.next(0x1B); diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index a03326c79..49b28d86f 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -170,6 +170,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "alt_sends_escape", .value = 1039 }, .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, .{ .name = "bracketed_paste", .value = 2004 }, + .{ .name = "synchronized_output", .value = 2026 }, }; test { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 4e3774e4d..57dc801b7 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -569,6 +569,35 @@ pub fn Stream(comptime Handler: type) type { ), }, + // DECRQM - Request Mode + 'p' => switch (action.intermediates.len) { + 2 => decrqm: { + if (action.intermediates[0] != '?' and + action.intermediates[1] != '$') + { + 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]); + } else log.warn("unimplemented DECRQM callback: {}", .{action}); + }, + + else => log.warn( + "ignoring unimplemented CSI p with intermediates: {s}", + .{action.intermediates}, + ), + }, + // DECSCUSR - Select Cursor Style // TODO: test 'q' => if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 007507e12..95b5ab962 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -293,9 +293,25 @@ pub fn resize( // Update our pixel sizes self.terminal.width_px = padded_size.width; self.terminal.height_px = padded_size.height; + + // Disable synchronized output mode so that we show changes + // immediately for a resize. This is allowed by the spec. + self.terminal.modes.set(.synchronized_output, false); + + // Wake up our renderer so any changes will be shown asap + self.renderer_wakeup.notify() catch {}; } } +/// Reset the synchronized output mode. This is usually called by timer +/// expiration from the termio thread. +pub fn resetSynchronizedOutput(self: *Exec) void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.terminal.modes.set(.synchronized_output, false); + self.renderer_wakeup.notify() catch {}; +} + /// Clear the screen. pub fn clearScreen(self: *Exec, history: bool) !void { { @@ -1272,6 +1288,27 @@ const StreamHandler = struct { } } + pub fn requestMode(self: *StreamHandler, mode_raw: u16) !void { + // Get the mode value and respond. + const code: u8 = code: { + if (!terminal.modes.hasSupport(mode_raw)) break :code 0; + if (self.terminal.modes.get(@enumFromInt(mode_raw))) break :code 1; + break :code 2; + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[?{};{}$y", + .{ + mode_raw, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + pub fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { // log.debug("save mode={}", .{mode}); self.terminal.modes.save(mode); @@ -1334,6 +1371,13 @@ const StreamHandler = struct { if (enabled) .@"132_cols" else .@"80_cols", ), + // We need to start a timer to prevent the emulator being hung + // forever. + .synchronized_output => { + if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); + try self.queueRender(); + }, + .mouse_event_x10 => self.terminal.flags.mouse_event = if (enabled) .x10 else .none, .mouse_event_normal => self.terminal.flags.mouse_event = if (enabled) .normal else .none, .mouse_event_button => self.terminal.flags.mouse_event = if (enabled) .button else .none, diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 2c32d459f..0b345ac1f 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -27,6 +27,10 @@ const Coalesce = struct { resize: ?termio.Message.Resize = null, }; +/// The number of milliseconds before we reset the synchronized output flag +/// if the running program hasn't already. +const sync_reset_ms = 1000; + /// Allocator used for some state alloc: std.mem.Allocator, @@ -49,6 +53,12 @@ coalesce_c: xev.Completion = .{}, coalesce_cancel_c: xev.Completion = .{}, coalesce_data: Coalesce = .{}, +/// This timer is used to reset synchronized output modes so that +/// the terminal doesn't freeze with a bad actor. +sync_reset: xev.Timer, +sync_reset_c: xev.Completion = .{}, +sync_reset_cancel_c: xev.Completion = .{}, + /// The underlying IO implementation. impl: *termio.Impl, @@ -79,6 +89,10 @@ pub fn init( var coalesce_h = try xev.Timer.init(); errdefer coalesce_h.deinit(); + // This timer is used to reset synchronized output modes. + var sync_reset_h = try xev.Timer.init(); + errdefer sync_reset_h.deinit(); + // The mailbox for messaging this thread var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); @@ -89,6 +103,7 @@ pub fn init( .wakeup = wakeup_h, .stop = stop_h, .coalesce = coalesce_h, + .sync_reset = sync_reset_h, .impl = impl, .mailbox = mailbox, }; @@ -98,6 +113,7 @@ pub fn init( /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { self.coalesce.deinit(); + self.sync_reset.deinit(); self.stop.deinit(); self.wakeup.deinit(); self.loop.deinit(); @@ -158,6 +174,7 @@ fn drainMailbox(self: *Thread) !void { .clear_screen => |v| try self.impl.clearScreen(v.history), .scroll_viewport => |v| try self.impl.scrollViewport(v), .jump_to_prompt => |v| try self.impl.jumpToPrompt(v), + .start_synchronized_output => self.startSynchronizedOutput(), .write_small => |v| try self.impl.queueWrite(v.data[0..v.len]), .write_stable => |v| try self.impl.queueWrite(v), .write_alloc => |v| { @@ -174,6 +191,18 @@ fn drainMailbox(self: *Thread) !void { } } +fn startSynchronizedOutput(self: *Thread) void { + self.sync_reset.reset( + &self.loop, + &self.sync_reset_c, + &self.sync_reset_cancel_c, + sync_reset_ms, + Thread, + self, + syncResetCallback, + ); +} + fn handleResize(self: *Thread, resize: termio.Message.Resize) void { self.coalesce_data.resize = resize; @@ -193,6 +222,25 @@ fn handleResize(self: *Thread, resize: termio.Message.Resize) void { ); } +fn syncResetCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + error.Canceled => {}, + else => { + log.warn("error during sync reset callback err={}", .{err}); + return .disarm; + }, + }; + + const self = self_ orelse return .disarm; + self.impl.resetSynchronizedOutput(); + return .disarm; +} + fn coalesceCallback( self_: ?*Thread, _: *xev.Loop, diff --git a/src/termio/message.zig b/src/termio/message.zig index d0fc9f7c3..06e8aae85 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -51,6 +51,11 @@ pub const Message = union(enum) { /// Jump forward/backward n prompts. jump_to_prompt: isize, + /// Send this when a synchronized output mode is started. This will + /// start the timer so that the output mode is disabled after a + /// period of time so that a bad actor can't hang the terminal. + start_synchronized_output: void, + /// Write where the data fits in the union. write_small: WriteReq.Small,