diff --git a/src/Surface.zig b/src/Surface.zig index 982d74118..baa8dc6b0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1826,17 +1826,12 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { // Schedule render which also drains our mailbox try self.queueRender(); - // Update the focus state and notify the terminal about the focus event if - // it is requesting it + // Update the focus state and notify the terminal { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; - const focus_event = self.io.terminal.modes.get(.focus_event); self.renderer_state.mutex.unlock(); - - if (focus_event) { - self.io.queueMessage(.{ .focused = focused }, .unlocked); - } + self.io.queueMessage(.{ .focused = focused }, .unlocked); } } diff --git a/src/pty.zig b/src/pty.zig index 47fd239c4..c0d082411 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -21,6 +21,20 @@ pub const Pty = switch (builtin.os.tag) { else => PosixPty, }; +/// The modes of a pty. Not all of these modes are supported on +/// all platforms but all platforms share the same mode struct. +/// +/// The default values of fields in this struct are set to the +/// most typical values for a pty. This makes it easier for cross-platform +/// code which doesn't support all of the modes to work correctly. +pub const Mode = packed struct { + /// ICANON on POSIX + canonical: bool = true, + + /// ECHO on POSIX + echo: bool = true, +}; + // A pty implementation that does nothing. // // TODO: This should be removed. This is only temporary until we have @@ -41,6 +55,11 @@ const NullPty = struct { _ = self; } + pub fn getMode(self: Pty) error{GetModeFailed}!Mode { + _ = self; + return .{}; + } + pub fn setSize(self: *Pty, size: winsize) !void { _ = self; _ = size; @@ -119,6 +138,17 @@ const PosixPty = struct { self.* = undefined; } + pub fn getMode(self: Pty) error{GetModeFailed}!Mode { + var attrs: c.termios = undefined; + if (c.tcgetattr(self.master, &attrs) != 0) + return error.GetModeFailed; + + return .{ + .canonical = (attrs.c_lflag & c.ICANON) != 0, + .echo = (attrs.c_lflag & c.ECHO) != 0, + }; + } + /// Return the size of the pty. pub fn getSize(self: Pty) !winsize { var ws: winsize = undefined; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 07c92c1b0..bb2a27f44 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -947,11 +947,14 @@ pub fn updateFrame( errdefer screen_copy.deinit(); // Whether to draw our cursor or not. - const cursor_style = renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); + const cursor_style = if (state.terminal.flags.password_input) + .lock + else + renderer.cursorStyle( + state, + self.focused, + cursor_blink_visible, + ); // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { @@ -2652,24 +2655,52 @@ fn addCursor( break :alpha @intFromFloat(@ceil(alpha)); }; - const sprite: font.Sprite = switch (cursor_style) { - .block => .cursor_rect, - .block_hollow => .cursor_hollow_rect, - .bar => .cursor_bar, - .underline => .underline, - }; + const render = switch (cursor_style) { + .block, + .block_hollow, + .bar, + .underline, + => render: { + const sprite: font.Sprite = switch (cursor_style) { + .block => .cursor_rect, + .block_hollow => .cursor_hollow_rect, + .bar => .cursor_bar, + .underline => .underline, + .lock => unreachable, + }; - const render = self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, + break :render self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + }; + }, + + .lock => self.font_grid.renderCodepoint( + self.alloc, + 0xF023, // lock symbol + .regular, + .text, + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + } orelse { + // This should never happen because we embed nerd + // fonts so we just log and return instead of fallback. + log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); + return; }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return; }; self.cells.setCursor(.{ diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 6d39eb445..a8f7c385c 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -767,11 +767,14 @@ pub fn updateFrame( errdefer screen_copy.deinit(); // Whether to draw our cursor or not. - const cursor_style = renderer.cursorStyle( - state, - self.focused, - cursor_blink_visible, - ); + const cursor_style = if (state.terminal.flags.password_input) + .lock + else + renderer.cursorStyle( + state, + self.focused, + cursor_blink_visible, + ); // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { @@ -1537,24 +1540,52 @@ fn addCursor( break :alpha @intFromFloat(@ceil(alpha)); }; - const sprite: font.Sprite = switch (cursor_style) { - .block => .cursor_rect, - .block_hollow => .cursor_hollow_rect, - .bar => .cursor_bar, - .underline => .underline, - }; + const render = switch (cursor_style) { + .block, + .block_hollow, + .bar, + .underline, + => render: { + const sprite: font.Sprite = switch (cursor_style) { + .block => .cursor_rect, + .block_hollow => .cursor_hollow_rect, + .bar => .cursor_bar, + .underline => .underline, + .lock => unreachable, + }; - const render = self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (wide) 2 else 1, - .grid_metrics = self.grid_metrics, + break :render self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return null; + }; + }, + + .lock => self.font_grid.renderCodepoint( + self.alloc, + 0xF023, // lock symbol + .regular, + .text, + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return null; + } orelse { + // This should never happen because we embed nerd + // fonts so we just log and return instead of fallback. + log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); + return null; }, - ) catch |err| { - log.warn("error rendering cursor glyph err={}", .{err}); - return null; }; try self.cells.append(self.alloc, .{ diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index d05acf9e9..d8769d9e2 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -6,11 +6,15 @@ const State = @import("State.zig"); /// This is a superset of terminal cursor styles since the renderer supports /// some additional cursor states such as the hollow block. pub const Style = enum { + // Typical cursor input styles block, block_hollow, bar, underline, + // Special cursor styles + lock, + /// Create a cursor style from the terminal style request. pub fn fromTerminal(term: terminal.CursorStyle) ?Style { return switch (term) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b3e19b708..882ef41c0 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -119,6 +119,11 @@ flags: packed struct { /// True if the window is focused. focused: bool = true, + /// True if the terminal is in a password entry mode. This is set + /// to true based on termios state. This is set + /// to true based on termios state. + password_input: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index f5c0c3326..ed55e301b 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -21,12 +21,16 @@ const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); const Command = @import("../Command.zig"); const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; -const Pty = @import("../pty.zig").Pty; +const ptypkg = @import("../pty.zig"); +const Pty = ptypkg.Pty; const EnvMap = std.process.EnvMap; const windows = internal_os.windows; const log = std.log.scoped(.io_exec); +/// The termios poll rate in milliseconds. +const TERMIOS_POLL_MS = 200; + /// The subprocess state for our exec backend. subprocess: Subprocess, @@ -114,6 +118,12 @@ pub fn threadEnter( var process = try xev.Process.init(pid); errdefer process.deinit(); + // Start our timer to read termios state changes. This is used + // to detect things such as when password input is being done + // so we can render the terminal in a different way. + var termios_timer = try xev.Timer.init(); + errdefer termios_timer.deinit(); + // Start our read thread const read_thread = try std.Thread.spawn( .{}, @@ -131,7 +141,9 @@ pub fn threadEnter( .process = process, .read_thread = read_thread, .read_thread_pipe = pipe[1], - .read_thread_fd = if (builtin.os.tag == .windows) pty_fds.read else {}, + .read_thread_fd = pty_fds.read, + .termios_timer = termios_timer, + .renderer_wakeup = io.renderer_wakeup, } }; // Start our process watcher @@ -142,6 +154,20 @@ pub fn threadEnter( td, processExit, ); + + // Start our termios timer. We only support this on Windows. + // Fundamentally, we could support this on Windows so we're just + // waiting for someone to implement it. + if (comptime builtin.os.tag != .windows) { + termios_timer.run( + td.loop, + &td.backend.exec.termios_timer_c, + TERMIOS_POLL_MS, + termio.Termio.ThreadData, + td, + termiosTimer, + ); + } } pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { @@ -170,6 +196,32 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { exec.read_thread.join(); } +pub fn focusGained( + self: *Exec, + td: *termio.Termio.ThreadData, + focused: bool, +) !void { + _ = self; + + assert(td.backend == .exec); + const execdata = &td.backend.exec; + + if (!focused) { + // Flag the timer to end on the next iteration. This is + // a lot cheaper than doing full timer cancellation. + execdata.termios_timer_running = false; + } else { + // If we're focused, we want to start our termios timer. We + // only do this if it isn't already running. We use the termios + // callback because that'll trigger an immediate state check AND + // start the timer. + if (execdata.termios_timer_c.state() != .active) { + execdata.termios_timer_running = true; + _ = termiosTimer(td, undefined, undefined, {}); + } + } +} + pub fn resize( self: *Exec, grid_size: renderer.GridSize, @@ -359,6 +411,85 @@ fn processExit( return .disarm; } +fn termiosTimer( + td_: ?*termio.Termio.ThreadData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + // log.debug("termios timer fired", .{}); + + // This should never happen because we guard starting our + // timer on windows but we want this assertion to fire if + // we ever do start the timer on windows. + // TODO: support on windows + if (comptime builtin.os.tag == .windows) { + @panic("termios timer not implemented on Windows"); + } + + _ = r catch |err| switch (err) { + // This is sent when our timer is canceled. That's fine. + error.Canceled => return .disarm, + + else => { + log.warn("error in termios timer callback err={}", .{err}); + @panic("crash in termios timer callback"); + }, + }; + + const td = td_.?; + assert(td.backend == .exec); + const exec = &td.backend.exec; + + // This is kind of hacky but we rebuild a Pty struct to get the + // termios data. + const mode: ptypkg.Mode = (Pty{ + .master = exec.read_thread_fd, + .slave = undefined, + }).getMode() catch |err| err: { + log.warn("error getting termios mode err={}", .{err}); + + // If we have an error we return the default mode values + // which are the likely values. + break :err .{}; + }; + + // If the mode changed, then we process it. + if (!std.meta.eql(mode, exec.termios_mode)) { + log.debug("termios change mode={}", .{mode}); + exec.termios_mode = mode; + + { + td.renderer_state.mutex.lock(); + defer td.renderer_state.mutex.unlock(); + const t = td.renderer_state.terminal; + + // We assume we're in some sort of password input if we're + // in canonical mode and not echoing. This is a heuristic. + t.flags.password_input = mode.canonical and !mode.echo; + } + + // Notify the renderer of our state change + exec.renderer_wakeup.notify() catch |err| { + log.warn("error notifying renderer err={}", .{err}); + }; + } + + // Repeat the timer + if (exec.termios_timer_running) { + exec.termios_timer.run( + td.loop, + &exec.termios_timer_c, + TERMIOS_POLL_MS, + termio.Termio.ThreadData, + td, + termiosTimer, + ); + } + + return .disarm; +} + pub fn queueWrite( self: *Exec, alloc: Allocator, @@ -498,7 +629,19 @@ pub const ThreadData = struct { /// Reader thread state read_thread: std.Thread, read_thread_pipe: posix.fd_t, - read_thread_fd: if (builtin.os.tag == .windows) posix.fd_t else void, + read_thread_fd: posix.fd_t, + + /// The timer to detect termios state changes. + termios_timer: xev.Timer, + termios_timer_c: xev.Completion = .{}, + termios_timer_running: bool = true, + + /// The last known termios mode. Used for change detection + /// to prevent unnecessary locking of expensive mutexes. + termios_mode: ptypkg.Mode = .{}, + + /// The handle to wake up the renderer. + renderer_wakeup: xev.Async, pub fn deinit(self: *ThreadData, alloc: Allocator) void { posix.close(self.read_thread_pipe); @@ -514,6 +657,9 @@ pub const ThreadData = struct { // Stop our write stream self.write_stream.deinit(); + + // Stop our termios timer + self.termios_timer.deinit(); } }; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f209748df..865ca8d90 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -523,8 +523,18 @@ pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !vo /// Called when focus is gained or lost (when focus events are enabled) pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { - const seq = if (focused) "\x1b[I" else "\x1b[O"; - try self.queueWrite(td, seq, false); + self.renderer_state.mutex.lock(); + const focus_event = self.renderer_state.terminal.modes.get(.focus_event); + self.renderer_state.mutex.unlock(); + + // If we have focus events enabled, we send the focus event. + if (focus_event) { + const seq = if (focused) "\x1b[I" else "\x1b[O"; + try self.queueWrite(td, seq, false); + } + + // We always notify our backend of focus changes. + try self.backend.focusGained(td, focused); } /// Process output from the pty. This is the manual API that users can diff --git a/src/termio/backend.zig b/src/termio/backend.zig index b29df89c6..0080e7628 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -62,6 +62,16 @@ pub const Backend = union(Kind) { } } + pub fn focusGained( + self: *Backend, + td: *termio.Termio.ThreadData, + focused: bool, + ) !void { + switch (self.*) { + .exec => |*exec| try exec.focusGained(td, focused), + } + } + pub fn resize( self: *Backend, grid_size: renderer.GridSize,