diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/Surface.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/Surface.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/Surface.zig | 6660 |
1 files changed, 6660 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig new file mode 100644 index 00000000..dfc3a50e --- /dev/null +++ b/vendor/libghostty-vt/src/Surface.zig @@ -0,0 +1,6660 @@ +//! Surface represents a single terminal "surface". A terminal surface is +//! a minimal "widget" where the terminal is drawn and responds to events +//! such as keyboard and mouse. Each surface also creates and owns its pty +//! session. +//! +//! The word "surface" is used because it is left to the higher level +//! application runtime to determine if the surface is a window, a tab, +//! a split, a preview pane in a larger window, etc. This struct doesn't care: +//! it just draws and responds to events. The events come from the application +//! runtime so the runtime can determine when and how those are delivered +//! (i.e. with focus, without focus, and so on). +const Surface = @This(); + +const apprt = @import("apprt.zig"); +pub const Mailbox = apprt.surface.Mailbox; +pub const Message = apprt.surface.Message; + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const global_state = &@import("global.zig").state; +const oni = @import("oniguruma"); +const crash = @import("crash/main.zig"); +const unicode = @import("unicode/main.zig"); +const rendererpkg = @import("renderer.zig"); +const termio = @import("termio.zig"); +const font = @import("font/main.zig"); +const Command = @import("Command.zig"); +const terminal = @import("terminal/main.zig"); +const configpkg = @import("config.zig"); +const Duration = configpkg.Config.Duration; +const input = @import("input.zig"); +const App = @import("App.zig"); +const internal_os = @import("os/main.zig"); +const inspectorpkg = @import("inspector/main.zig"); +const SurfaceMouse = @import("surface_mouse.zig"); +const ProcessInfo = @import("pty.zig").ProcessInfo; + +const log = std.log.scoped(.surface); + +// The renderer implementation to use. +const Renderer = rendererpkg.Renderer; + +/// Minimum window size in cells. This is used to prevent the window from +/// being resized to a size that is too small to be useful. These defaults +/// are chosen to match the default size of Mac's Terminal.app, but is +/// otherwise somewhat arbitrary. +pub const min_window_width_cells: u32 = 10; +pub const min_window_height_cells: u32 = 4; + +/// The maximum number of key tables that can be active at any +/// given time. `activate_key_table` calls after this are ignored. +const max_active_key_tables = 8; + +/// Unique ID used to identify this surface for IPC purposes. It is +/// exposed to the commands running in surfaces as the environment variable +/// GHOSTTY_SURFACE_ID. It must not be zero as zero is used to incicate a null +/// value when communicating an ID over DBus as DBus does not allow null/maybe +/// values. +id: u64, + +/// Allocator +alloc: Allocator, + +/// The app that this surface is attached to. +app: *App, + +/// The windowing system surface and app. +rt_app: *apprt.runtime.App, +rt_surface: *apprt.runtime.Surface, + +/// The font structures +font_grid_key: font.SharedGridSet.Key, +font_size: font.face.DesiredSize, +font_metrics: font.Metrics, + +/// This keeps track of if the font size was ever modified. If it wasn't, +/// then config reloading will change the font. If it was manually adjusted, +/// we don't change it on config reload since we assume the user wants +/// a specific size. +font_size_adjusted: bool, + +/// The renderer for this surface. +renderer: Renderer, + +/// The render state +renderer_state: rendererpkg.State, + +/// The renderer thread manager +renderer_thread: rendererpkg.Thread, + +/// The actual thread +renderer_thr: std.Thread, + +/// Mouse state. +mouse: Mouse, + +/// Keyboard input state. +keyboard: Keyboard, + +/// A currently pressed key. This is used so that we can send a keyboard +/// release event when the surface is unfocused. Note that when the surface +/// is refocused, a key press event may not be sent again -- this depends +/// on the apprt (UI framework) in use, but we want to consistently send +/// a release. +/// +/// This is only sent when a keypress event results in a key event being +/// sent to the pty. If it is consumed by a keybinding or other action, +/// this is not set. +/// +/// Also note the utf8 value is not valid for this event so some unfocused +/// release events may not send exactly the right data within Kitty keyboard +/// events. This seems unspecified in the spec so for now I'm okay with +/// this. Plus, its only for release events where the key text is far +/// less important. +pressed_key: ?input.KeyEvent = null, + +/// The hash value of the last keybinding trigger that we performed. This +/// is only set if the last key input matched a keybinding, consumed it, +/// and performed it. This is used to prevent sending release/repeat events +/// for handled bindings. +last_binding_trigger: u64 = 0, + +/// The terminal IO handler. +io: termio.Termio, +io_thread: termio.Thread, +io_thr: std.Thread, + +/// Terminal inspector +inspector: ?*inspectorpkg.Inspector = null, + +/// All our sizing information. +size: rendererpkg.Size, + +/// The configuration derived from the main config. We "derive" it so that +/// we don't have a shared pointer hanging around that we need to worry about +/// the lifetime of. This makes updating config at runtime easier. +config: DerivedConfig, + +/// The conditional state of the configuration. This can affect +/// how certain configurations take effect such as light/dark mode. +/// This is managed completely by Ghostty core but an apprt action +/// is sent whenever this changes. +config_conditional_state: configpkg.ConditionalState, + +/// This is set to true if our IO thread notifies us our child exited. +/// This is used to determine if we need to confirm, hold open, etc. +child_exited: bool = false, + +/// We maintain our focus state and assume we're focused by default. +/// If we're not initially focused then apprts can call focusCallback +/// to let us know. +focused: bool = true, + +/// Used to determine whether to continuously scroll. +selection_scroll_active: bool = false, + +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// (native) scrolling, and copy keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + +/// Used to send notifications that long running commands have finished. +/// Requires that shell integration be active. Should represent a nanosecond +/// precision timestamp. It does not necessarily need to correspond to the +/// actual time, but we must be able to compare two subsequent timestamps to get +/// the wall clock time that has elapsed between timestamps. +command_timer: ?std.time.Instant = null, + +/// Search state +search: ?Search = null, + +/// Used to rate limit BEL handling. +last_bell_time: ?std.time.Instant = null, + +/// The effect of an input event. This can be used by callers to take +/// the appropriate action after an input event. For example, key +/// input can be forwarded to the OS for further processing if it +/// wasn't handled in any way by Ghostty. +pub const InputEffect = enum { + /// The input was not handled in any way by Ghostty and should be + /// forwarded to other subsystems (i.e. the OS) for further + /// processing. + ignored, + + /// The input was handled and consumed by Ghostty. + consumed, + + /// The input resulted in a close event for this surface so + /// the surface, runtime surface, etc. pointers may all be + /// unsafe to use so exit immediately. + closed, +}; + +/// The search state for the surface. +const Search = struct { + state: terminal.search.Thread, + thread: std.Thread, + + pub fn deinit(self: *Search) void { + // Notify the thread to stop + self.state.stop.notify() catch |err| log.err( + "error notifying search thread to stop, may stall err={}", + .{err}, + ); + + // Wait for the OS thread to quit + self.thread.join(); + + // Now it is safe to deinit the state + self.state.deinit(); + } +}; + +/// Mouse state for the surface. +const Mouse = struct { + /// The last tracked mouse button state by button. + click_state: [input.MouseButton.max]input.MouseButtonState = @splat(.release), + + /// The last mods state when the last mouse button (whatever it was) was + /// pressed or release. + mods: input.Mods = .{}, + + /// The point at which the left mouse click happened. This is in screen + /// coordinates so that scrolling preserves the location. + left_click_pin: ?*terminal.Pin = null, + left_click_screen: terminal.ScreenSet.Key = .primary, + + /// The starting xpos/ypos of the left click. Note that if scrolling occurs, + /// these will point to different "cells", but the xpos/ypos will stay + /// stable during scrolling relative to the surface. + left_click_xpos: f64 = 0, + left_click_ypos: f64 = 0, + + /// The count of clicks to count double and triple clicks and so on. + /// The left click time was the last time the left click was done. This + /// is always set on the first left click. + left_click_count: u8 = 0, + left_click_time: std.time.Instant = undefined, + + /// The last x/y sent for mouse reports. + event_point: ?terminal.point.Coordinate = null, + + /// The pressure stage for the mouse. This should always be none if + /// the mouse is not pressed. + pressure_stage: input.MousePressureStage = .none, + + /// Pending scroll amounts for high-precision scrolls + pending_scroll_x: f64 = 0, + pending_scroll_y: f64 = 0, + + /// True if the mouse is hidden + hidden: bool = false, + + /// True if the mouse position is currently over a link. + over_link: bool = false, + + /// The last x/y in the cursor position for links. We use this to + /// only process link hover events when the mouse actually moves cells. + link_point: ?terminal.point.Coordinate = null, +}; + +/// Keyboard state for the surface. +pub const Keyboard = struct { + /// The currently active key sequence for the surface. If this is null + /// then we're not currently in a key sequence. + sequence_set: ?*const input.Binding.Set = null, + + /// The queued keys when we're in the middle of a sequenced binding. + /// These are flushed when the sequence is completed and unconsumed or + /// invalid. + /// + /// This is naturally bounded due to the configuration maximum + /// length of a sequence. + sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty, + + /// The stack of tables that is currently active. The first value + /// in this is the first activated table (NOT the default keybinding set). + /// + /// This is bounded by `max_active_key_tables`. + table_stack: std.ArrayListUnmanaged(struct { + set: *const input.Binding.Set, + once: bool, + }) = .empty, + + /// The last handled binding. This is used to prevent encoding release + /// events for handled bindings. We only need to keep track of one because + /// at least at the time of writing this, its impossible for two keys of + /// a combination to be handled by different bindings before the release + /// of the prior (namely since you can't bind modifier-only). + last_trigger: ?u64 = null, +}; + +/// The configuration that a surface has, this is copied from the main +/// Config struct usually to prevent sharing a single value. +const DerivedConfig = struct { + arena: ArenaAllocator, + + /// For docs for these, see the associated config they are derived from. + original_font_size: f32, + keybind: configpkg.Keybinds, + abnormal_command_exit_runtime_ms: u32, + clipboard_read: configpkg.ClipboardAccess, + clipboard_write: configpkg.ClipboardAccess, + clipboard_trim_trailing_spaces: bool, + clipboard_paste_protection: bool, + clipboard_paste_bracketed_safe: bool, + clipboard_codepoint_map: configpkg.Config.RepeatableClipboardCodepointMap, + copy_on_select: configpkg.CopyOnSelect, + right_click_action: configpkg.RightClickAction, + confirm_close_surface: configpkg.ConfirmCloseSurface, + cursor_click_to_move: bool, + desktop_notifications: bool, + font: font.SharedGridSet.DerivedConfig, + mouse_interval: u64, + mouse_hide_while_typing: bool, + mouse_reporting: bool, + mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, + mouse_shift_capture: configpkg.MouseShiftCapture, + fullscreen: configpkg.Fullscreen, + macos_non_native_fullscreen: configpkg.NonNativeFullscreen, + macos_option_as_alt: ?input.OptionAsAlt, + selection_clear_on_copy: bool, + selection_clear_on_typing: bool, + selection_word_chars: []const u21, + vt_kam_allowed: bool, + wait_after_command: bool, + window_padding_top: u32, + window_padding_bottom: u32, + window_padding_left: u32, + window_padding_right: u32, + window_padding_balance: configpkg.Config.WindowPaddingBalance, + window_height: u32, + window_width: u32, + title: ?[:0]const u8, + title_report: bool, + links: []DerivedConfig.Link, + link_previews: configpkg.LinkPreviews, + scroll_to_bottom: configpkg.Config.ScrollToBottom, + notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, + notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction, + notify_on_command_finish_after: Duration, + key_remaps: input.KeyRemapSet, + + const Link = struct { + regex: oni.Regex, + action: input.Link.Action, + highlight: input.Link.Highlight, + }; + + pub fn init(alloc_gpa: Allocator, config: *const configpkg.Config) !DerivedConfig { + var arena = ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Build all of our links + const links = links: { + var links: std.ArrayList(DerivedConfig.Link) = .empty; + defer links.deinit(alloc); + for (config.link.links.items) |link| { + var regex = try link.oniRegex(); + errdefer regex.deinit(); + try links.append(alloc, .{ + .regex = regex, + .action = link.action, + .highlight = link.highlight, + }); + } + + break :links try links.toOwnedSlice(alloc); + }; + errdefer { + for (links) |*link| link.regex.deinit(); + alloc.free(links); + } + + return .{ + .original_font_size = config.@"font-size", + .keybind = try config.keybind.clone(alloc), + .abnormal_command_exit_runtime_ms = config.@"abnormal-command-exit-runtime", + .clipboard_read = config.@"clipboard-read", + .clipboard_write = config.@"clipboard-write", + .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", + .clipboard_paste_protection = config.@"clipboard-paste-protection", + .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", + .clipboard_codepoint_map = try config.@"clipboard-codepoint-map".clone(alloc), + .copy_on_select = config.@"copy-on-select", + .right_click_action = config.@"right-click-action", + .confirm_close_surface = config.@"confirm-close-surface", + .cursor_click_to_move = config.@"cursor-click-to-move", + .desktop_notifications = config.@"desktop-notifications", + .font = try font.SharedGridSet.DerivedConfig.init(alloc, config), + .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms + .mouse_hide_while_typing = config.@"mouse-hide-while-typing", + .mouse_reporting = config.@"mouse-reporting", + .mouse_scroll_multiplier = config.@"mouse-scroll-multiplier", + .mouse_shift_capture = config.@"mouse-shift-capture", + .fullscreen = config.fullscreen, + .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", + .macos_option_as_alt = config.@"macos-option-as-alt", + .selection_clear_on_copy = config.@"selection-clear-on-copy", + .selection_clear_on_typing = config.@"selection-clear-on-typing", + .selection_word_chars = try alloc.dupe(u21, config.@"selection-word-chars".codepoints), + .vt_kam_allowed = config.@"vt-kam-allowed", + .wait_after_command = config.@"wait-after-command", + .window_padding_top = config.@"window-padding-y".top_left, + .window_padding_bottom = config.@"window-padding-y".bottom_right, + .window_padding_left = config.@"window-padding-x".top_left, + .window_padding_right = config.@"window-padding-x".bottom_right, + .window_padding_balance = config.@"window-padding-balance", + .window_height = config.@"window-height", + .window_width = config.@"window-width", + .title = config.title, + .title_report = config.@"title-report", + .links = links, + .link_previews = config.@"link-previews", + .scroll_to_bottom = config.@"scroll-to-bottom", + .notify_on_command_finish = config.@"notify-on-command-finish", + .notify_on_command_finish_action = config.@"notify-on-command-finish-action", + .notify_on_command_finish_after = config.@"notify-on-command-finish-after", + .key_remaps = try config.@"key-remap".clone(alloc), + + // Assignments happen sequentially so we have to do this last + // so that the memory is captured from allocs above. + .arena = arena, + }; + } + + pub fn deinit(self: *DerivedConfig) void { + for (self.links) |*link| link.regex.deinit(); + self.arena.deinit(); + } + + fn scaledPadding(self: *const DerivedConfig, x_dpi: f32, y_dpi: f32) rendererpkg.Padding { + const padding_top: u32 = padding_top: { + const padding_top: f32 = @floatFromInt(self.window_padding_top); + break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72)); + }; + const padding_bottom: u32 = padding_bottom: { + const padding_bottom: f32 = @floatFromInt(self.window_padding_bottom); + break :padding_bottom @intFromFloat(@floor(padding_bottom * y_dpi / 72)); + }; + const padding_left: u32 = padding_left: { + const padding_left: f32 = @floatFromInt(self.window_padding_left); + break :padding_left @intFromFloat(@floor(padding_left * x_dpi / 72)); + }; + const padding_right: u32 = padding_right: { + const padding_right: f32 = @floatFromInt(self.window_padding_right); + break :padding_right @intFromFloat(@floor(padding_right * x_dpi / 72)); + }; + + return .{ + .top = padding_top, + .bottom = padding_bottom, + .left = padding_left, + .right = padding_right, + }; + } +}; + +/// Create a new surface. This must be called from the main thread. The +/// pointer to the memory for the surface must be provided and must be +/// stable due to interfacing with various callbacks. +pub fn init( + self: *Surface, + alloc: Allocator, + config_original: *const configpkg.Config, + app: *App, + rt_app: *apprt.runtime.App, + rt_surface: *apprt.runtime.Surface, +) !void { + // Apply our conditional state. If we fail to apply the conditional state + // then we log and attempt to move forward with the old config. + var config_: ?configpkg.Config = config_original.changeConditionalState( + app.config_conditional_state, + ) catch |err| err: { + log.warn("failed to apply conditional state to config err={}", .{err}); + break :err null; + }; + defer if (config_) |*c| c.deinit(); + + // We want a config pointer for everything so we get that either + // based on our conditional state or the original config. + const config: *const configpkg.Config = if (config_) |*c| config: { + // We want to preserve our original working directory. We + // don't need to dupe memory here because termio will derive + // it. We preserve this so directory inheritance works. + c.@"working-directory" = config_original.@"working-directory"; + break :config c; + } else config_original; + + // Get our configuration + var derived_config = try DerivedConfig.init(alloc, config); + errdefer derived_config.deinit(); + + // Initialize our renderer with our initialized surface. + try Renderer.surfaceInit(rt_surface); + + // Determine our DPI configurations so we can properly configure + // font points to pixels and handle other high-DPI scaling factors. + const content_scale = try rt_surface.getContentScale(); + const x_dpi = content_scale.x * font.face.default_dpi; + const y_dpi = content_scale.y * font.face.default_dpi; + log.debug("xscale={} yscale={} xdpi={} ydpi={}", .{ + content_scale.x, + content_scale.y, + x_dpi, + y_dpi, + }); + + // The font size we desire along with the DPI determined for the surface + const font_size: font.face.DesiredSize = .{ + .points = config.@"font-size", + .xdpi = @intFromFloat(x_dpi), + .ydpi = @intFromFloat(y_dpi), + }; + + // Setup our font group. This will reuse an existing font group if + // it was already loaded. + const font_grid_key, const font_grid = try app.font_grid_set.ref( + &derived_config.font, + font_size, + ); + + // Build our size struct which has all the sizes we need. + const size: rendererpkg.Size = size: { + var size: rendererpkg.Size = .{ + .screen = screen: { + const surface_size = try rt_surface.getSize(); + break :screen .{ + .width = surface_size.width, + .height = surface_size.height, + }; + }, + + .cell = font_grid.cellSize(), + .padding = .{}, + }; + + const explicit: rendererpkg.Padding = derived_config.scaledPadding( + x_dpi, + y_dpi, + ); + if (derived_config.window_padding_balance != .false) { + size.balancePadding(explicit, derived_config.window_padding_balance); + } else { + size.padding = explicit; + } + + break :size size; + }; + + // Create our terminal grid with the initial size + const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox }; + var renderer_impl = try Renderer.init(alloc, .{ + .config = try .init(alloc, config), + .font_grid = font_grid, + .size = size, + .surface_mailbox = .{ .surface = self, .app = app_mailbox }, + .rt_surface = rt_surface, + .thread = &self.renderer_thread, + }); + errdefer renderer_impl.deinit(); + + // The mutex used to protect our renderer state. + const mutex = try alloc.create(std.Thread.Mutex); + mutex.* = .{}; + errdefer alloc.destroy(mutex); + + // Create the renderer thread + var render_thread = try rendererpkg.Thread.init( + alloc, + config, + rt_surface, + &self.renderer, + &self.renderer_state, + app_mailbox, + ); + errdefer render_thread.deinit(); + + // Create the IO thread + var io_thread = try termio.Thread.init(alloc); + errdefer io_thread.deinit(); + + self.* = .{ + .id = id: { + while (true) { + const candidate = std.crypto.random.int(u64); + if (candidate == 0) continue; + break :id candidate; + } + }, + .alloc = alloc, + .app = app, + .rt_app = rt_app, + .rt_surface = rt_surface, + .font_grid_key = font_grid_key, + .font_size = font_size, + .font_size_adjusted = false, + .font_metrics = font_grid.metrics, + .renderer = renderer_impl, + .renderer_thread = render_thread, + .renderer_state = .{ + .mutex = mutex, + .terminal = &self.io.terminal, + }, + .renderer_thr = undefined, + .mouse = .{}, + .keyboard = .{}, + .io = undefined, + .io_thread = io_thread, + .io_thr = undefined, + .size = size, + .config = derived_config, + + // Our conditional state is initialized to the app state. This + // lets us get the most likely correct color theme and so on. + .config_conditional_state = app.config_conditional_state, + }; + + // The command we're going to execute + const command: ?configpkg.Command = command: { + if (app.first) { + if (config.@"initial-command") |command| { + break :command command; + } + } + break :command config.command; + }; + + // Start our IO implementation + // This separate block ({}) is important because our errdefers must + // be scoped here to be valid. + { + var env = rt_surface.defaultTermioEnv() catch |err| env: { + // If an error occurs, we don't want to block surface startup. + log.warn("error getting env map for surface err={}", .{err}); + break :env internal_os.getEnvMap(alloc) catch + std.process.EnvMap.init(alloc); + }; + errdefer env.deinit(); + + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + + var buf: [18]u8 = undefined; + try env.put( + "GHOSTTY_SURFACE_ID", + std.fmt.bufPrint(&buf, "0x{x:0>16}", .{self.id}) catch unreachable, + ); + + // Initialize our IO backend + var io_exec = try termio.Exec.init(alloc, .{ + .command = command, + .env = env, + .env_override = config.env, + .shell_integration = config.@"shell-integration", + .shell_integration_features = config.@"shell-integration-features", + .cursor_blink = config.@"cursor-style-blink", + .working_directory = if (config.@"working-directory") |wd| wd.value() else null, + .resources_dir = global_state.resources_dir.host(), + .term = config.term, + .rt_pre_exec_info = .init(config), + .rt_post_fork_info = .init(config), + }); + errdefer io_exec.deinit(); + + // Initialize our IO mailbox + var io_mailbox = try termio.Mailbox.initSPSC(alloc); + errdefer io_mailbox.deinit(alloc); + + try termio.Termio.init(&self.io, alloc, .{ + .size = size, + .full_config = config, + .config = try termio.Termio.DerivedConfig.init(alloc, config), + .backend = .{ .exec = io_exec }, + .mailbox = io_mailbox, + .renderer_state = &self.renderer_state, + .renderer_wakeup = render_thread.wakeup, + .renderer_mailbox = render_thread.mailbox, + .surface_mailbox = .{ .surface = self, .app = app_mailbox }, + }); + } + // Outside the block, IO has now taken ownership of our temporary state + // so we can just defer this and not the subcomponents. + errdefer self.io.deinit(); + + // Report initial cell size on surface creation + _ = try rt_app.performAction( + .{ .surface = self }, + .cell_size, + .{ .width = size.cell.width, .height = size.cell.height }, + ); + + _ = try rt_app.performAction( + .{ .surface = self }, + .size_limit, + .{ + .min_width = size.cell.width * min_window_width_cells, + .min_height = size.cell.height * min_window_height_cells, + // No max: + .max_width = 0, + .max_height = 0, + }, + ); + + // Call our size callback which handles all our retina setup + // Note: this shouldn't be necessary and when we clean up the surface + // init stuff we should get rid of this. But this is required because + // sizeCallback does retina-aware stuff we don't do here and don't want + // to duplicate. + try self.resize(self.size.screen); + + // Give the renderer one more opportunity to finalize any surface + // setup on the main thread prior to spinning up the rendering thread. + try renderer_impl.finalizeSurfaceInit(rt_surface); + + // Start our renderer thread + self.renderer_thr = try std.Thread.spawn( + .{}, + rendererpkg.Thread.threadMain, + .{&self.renderer_thread}, + ); + self.renderer_thr.setName("renderer") catch {}; + + // Start our IO thread + self.io_thr = try std.Thread.spawn( + .{}, + termio.Thread.threadMain, + .{ &self.io_thread, &self.io }, + ); + self.io_thr.setName("io") catch {}; + + // Determine our initial window size if configured. We need to do this + // quite late in the process because our height/width are in grid dimensions, + // so we need to know our cell sizes first. + // + // Note: it is important to do this after the renderer is setup above. + // This allows the apprt to fully initialize the surface before we + // start messing with the window. + self.recomputeInitialSize() catch |err| { + // We don't treat this as a fatal error because not setting + // an initial size shouldn't stop our terminal from working. + log.warn("unable to set initial window size: {}", .{err}); + }; + + if (config.title) |title| { + _ = try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + } else if ((comptime builtin.os.tag == .linux) and + config.@"_xdg-terminal-exec") + xdg: { + // For xdg-terminal-exec execution we special-case and set the window + // title to the command being executed. This allows window managers + // to set custom styling based on the command being executed. + const v = command orelse break :xdg; + const title = v.string(alloc) catch |err| { + log.warn( + "error copying command for title, title will not be set err={}", + .{err}, + ); + break :xdg; + }; + defer alloc.free(title); + _ = try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + } else if (command) |cmd| switch (cmd) { + // If a user specifies a command it is appropriate to set the title as argv[0] + // we know in the case of a direct command it has been supplied by the user + .direct => |cmd_str| if (cmd_str.len != 0) { + _ = try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = cmd_str[0] }, + ); + }, + + // We won't set the title in the case the shell expands the command + // as that should typically be used to launch a shell which should + // set its own titles + .shell => {}, + }; + + // We are no longer the first surface + app.first = false; +} + +pub fn deinit(self: *Surface) void { + // Stop search thread + if (self.search) |*s| s.deinit(); + + // Stop rendering thread + { + self.renderer_thread.stop.notify() catch |err| + log.err("error notifying renderer thread to stop, may stall err={}", .{err}); + self.renderer_thr.join(); + + // We need to become the active rendering thread again + self.renderer.threadEnter(self.rt_surface) catch unreachable; + } + + // Stop our IO thread + { + self.io_thread.stop.notify() catch |err| + log.err("error notifying io thread to stop, may stall err={}", .{err}); + self.io_thr.join(); + } + + // We need to deinit AFTER everything is stopped, since there are + // shared values between the two threads. + self.renderer_thread.deinit(); + self.renderer.deinit(); + self.io_thread.deinit(); + self.io.deinit(); + + if (self.inspector) |v| { + v.deinit(self.alloc); + self.alloc.destroy(v); + } + + // Clean up our keyboard state + for (self.keyboard.sequence_queued.items) |req| req.deinit(); + self.keyboard.sequence_queued.deinit(self.alloc); + self.keyboard.table_stack.deinit(self.alloc); + + // Clean up our font grid + self.app.font_grid_set.deref(self.font_grid_key); + + // Clean up our render state + if (self.renderer_state.preedit) |p| self.alloc.free(p.codepoints); + self.alloc.destroy(self.renderer_state.mutex); + self.config.deinit(); + + log.info("surface closed addr={x}", .{@intFromPtr(self)}); +} + +/// Close this surface. This will trigger the runtime to start the +/// close process, which should ultimately deinitialize this surface. +pub fn close(self: *Surface) void { + self.rt_surface.close(self.needsConfirmQuit()); +} + +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + +/// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + + self.io.queueMessage(msg, mutex); +} + +/// Forces the surface to render. This is useful for when the surface +/// is in the middle of animation (such as a resize, etc.) or when +/// the render timer is managed manually by the apprt. +pub fn draw(self: *Surface) !void { + // Renderers are required to support `drawFrame` being called from + // the main thread, so that they can update contents during resize. + try self.renderer.drawFrame(true); +} + +/// Activate the inspector. This will begin collecting inspection data. +/// This will not affect the GUI. The GUI must use performAction to +/// show/hide the inspector UI. +pub fn activateInspector(self: *Surface) !void { + if (self.inspector != null) return; + + // Setup the inspector + const ptr = try self.alloc.create(inspectorpkg.Inspector); + errdefer self.alloc.destroy(ptr); + ptr.* = try inspectorpkg.Inspector.init(self.alloc); + errdefer ptr.deinit(self.alloc); + self.inspector = ptr; + errdefer self.inspector = null; + + // Put the inspector onto the render state + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + assert(self.renderer_state.inspector == null); + self.renderer_state.inspector = self.inspector; + } + + // Notify our components we have an inspector active + _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); + self.queueIo(.{ .inspector = true }, .unlocked); +} + +/// Deactivate the inspector and stop collecting any information. +pub fn deactivateInspector(self: *Surface) void { + const insp = self.inspector orelse return; + + // Remove the inspector from the render state + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + assert(self.renderer_state.inspector != null); + self.renderer_state.inspector = null; + } + + // Notify our components we have deactivated inspector + _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); + self.queueIo(.{ .inspector = false }, .unlocked); + + // Deinit the inspector + insp.deinit(self.alloc); + self.alloc.destroy(insp); + self.inspector = null; +} + +/// True if the surface requires confirmation to quit. This should be called +/// by apprt to determine if the surface should confirm before quitting. +pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + + // If the child has exited, then our process is certainly not alive. + // We check this first to avoid the locking overhead below. + if (self.child_exited) return false; + + // Check the configuration for confirming close behavior. + return switch (self.config.confirm_close_surface) { + .always => true, + .false => false, + .true => true: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + break :true !self.io.terminal.cursorIsAtPrompt(); + }, + }; +} + +/// Called from the app thread to handle mailbox messages to our specific +/// surface. +pub fn handleMessage(self: *Surface, msg: Message) !void { + switch (msg) { + .change_config => |config| try self.updateConfig(config), + + .set_title => |*v| { + // We ignore the message in case the title was set via config. + if (self.config.title != null) { + log.debug("ignoring title change request since static title is set via config", .{}); + return; + } + + // The ptrCast just gets sliceTo to return the proper type. + // We know that our title should end in 0. + const slice = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(v)), 0); + log.debug("changing title \"{s}\"", .{slice}); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = slice }, + ); + }, + + .report_title => |style| report_title: { + if (!self.config.title_report) { + log.info("report_title requested, but disabled via config", .{}); + break :report_title; + } + + const title: ?[:0]const u8 = self.rt_surface.getTitle(); + const data = switch (style) { + .csi_21_t => try std.fmt.allocPrint( + self.alloc, + "\x1b]l{s}\x1b\\", + .{title orelse ""}, + ), + }; + + // We always use an allocating message because we don't know + // the length of the title and this isn't a performance critical + // path. + self.queueIo(.{ + .write_alloc = .{ + .alloc = self.alloc, + .data = data, + }, + }, .unlocked); + }, + + .color_change => |change| color_change: { + // Notify our apprt, but don't send a mode 2031 DSR report + // because VT sequences were used to change the color. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .color_change, + .{ + .kind = switch (change.target) { + .palette => |v| @enumFromInt(v), + .dynamic => |dyn| switch (dyn) { + .foreground => .foreground, + .background => .background, + .cursor => .cursor, + // Unsupported dynamic color change notification type + else => break :color_change, + }, + // Special colors aren't supported for change notification + .special => break :color_change, + }, + .r = change.color.r, + .g = change.color.g, + .b = change.color.b, + }, + ); + }, + + .set_mouse_shape => |shape| { + log.debug("changing mouse shape: {}", .{shape}); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + shape, + ); + }, + + .clipboard_read => |clipboard| { + if (self.config.clipboard_read == .deny) { + log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); + return; + } + + _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); + }, + + .clipboard_write => |w| switch (w.req) { + .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), + .stable => |v| try self.clipboardWrite(v, w.clipboard_type), + .alloc => |v| { + defer v.alloc.free(v.data); + try self.clipboardWrite(v.data, w.clipboard_type); + }, + }, + + .pwd_change => |w| { + defer w.deinit(); + + // We always allocate for this because we need to null-terminate. + const str = try self.alloc.dupeZ(u8, w.slice()); + defer self.alloc.free(str); + + _ = try self.rt_app.performAction( + .{ .surface = self }, + .pwd, + .{ .pwd = str }, + ); + }, + + .close => self.close(), + + .child_exited => |v| self.childExited(v), + + .desktop_notification => |notification| { + if (!self.config.desktop_notifications) { + log.info("application attempted to display a desktop notification, but 'desktop-notifications' is disabled", .{}); + return; + } + + const title = std.mem.sliceTo(¬ification.title, 0); + const body = std.mem.sliceTo(¬ification.body, 0); + try self.showDesktopNotification(title, body); + }, + + .renderer_health => |health| self.updateRendererHealth(health), + + .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), + + .present_surface => try self.presentSurface(), + + .password_input => |v| try self.passwordInput(v), + + .ring_bell => bell: { + const now = std.time.Instant.now() catch unreachable; + if (self.last_bell_time) |last| { + if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + } + self.last_bell_time = now; + _ = self.rt_app.performAction( + .{ .surface = self }, + .ring_bell, + {}, + ) catch |err| { + log.warn("apprt failed to ring bell={}", .{err}); + }; + }, + + .progress_report => |v| { + _ = self.rt_app.performAction( + .{ .surface = self }, + .progress_report, + v, + ) catch |err| { + log.warn("apprt failed to report progress err={}", .{err}); + }; + }, + + .selection_scroll_tick => |active| { + self.selection_scroll_active = active; + try self.selectionScrollTick(); + }, + + .start_command => { + self.command_timer = try .now(); + }, + + .stop_command => |v| timer: { + const end: std.time.Instant = try .now(); + const start = self.command_timer orelse break :timer; + self.command_timer = null; + + const duration: Duration = .{ .duration = end.since(start) }; + log.debug("command took {f}", .{duration}); + + _ = self.rt_app.performAction( + .{ .surface = self }, + .command_finished, + .{ + .exit_code = v, + .duration = duration, + }, + ) catch |err| { + log.warn("apprt failed to notify command finish={}", .{err}); + }; + }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, + } +} + +fn selectionScrollTick(self: *Surface) !void { + // If we're no longer active then we don't do anything. + if (!self.selection_scroll_active) return; + + // If we don't have a left mouse button down then we + // don't do anything. + if (self.mouse.left_click_count == 0) return; + + const pos = try self.rt_surface.getCursorPos(); + const pos_vp = self.posToViewport(pos.x, pos.y); + const delta: isize = if (pos.y < 0) -1 else 1; + + // We need our locked state for the remainder + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // If our screen changed while this is happening, we stop our + // selection scroll. + if (self.mouse.left_click_screen != t.screens.active_key) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + return; + } + + // Scroll the viewport as required + t.scrollViewport(.{ .delta = delta }); + + // Next, trigger our drag behavior + const pin = t.screens.active.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + try self.dragLeftClickSingle(pin, pos.x); + + // We modified our viewport and selection so we need to queue + // a render. + try self.queueRender(); +} + +fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { + // Mark our flag that we exited immediately + self.child_exited = true; + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (info.runtime_ms <= self.config.abnormal_command_exit_runtime_ms) runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime !builtin.target.os.tag.isDarwin()) { + // If the exit code is 0 then it was a good exit. + if (info.exit_code == 0) break :runtime; + } + + log.warn("abnormal process exit detected, showing error message", .{}); + + // Try and show a GUI message. If it returns true, don't do anything else. + if (self.rt_app.performAction( + .{ .surface = self }, + .show_child_exited, + info, + ) catch |err| gui: { + log.err("error trying to show native child exited GUI err={}", .{err}); + break :gui false; + }) return; + + // If a native GUI notification was not shown, update our terminal to + // note the abnormal exit. + self.childExitedAbnormally(info) catch |err| { + log.err("error handling abnormal child exit err={}", .{err}); + return; + }; + + return; + } + + // We output a message so that the user knows what's going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + // First try and show a native GUI message. + if (self.rt_app.performAction( + .{ .surface = self }, + .show_child_exited, + info, + ) catch |err| gui: { + log.err("error trying to show native child exited GUI err={}", .{err}); + break :gui false; + }) break :terminal; + + // If the native GUI can't be shown, display a text message in the + // terminal. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + + // We also want to ensure that normal keyboard encoding is on + // so that we can close the terminal. We close the terminal on + // any key press that encodes a character. + t.modes.set(.disable_keyboard, false); + t.screens.active.kitty_keyboard.set(.set, .disabled); + } + + // Waiting after command we stop here. The terminal is updated, our + // state is updated, and now its up to the user to decide what to do. + if (self.config.wait_after_command) return; + + // If we aren't waiting after the command, then we exit immediately + // with no confirmation. + self.close(); +} + +/// Called when the child process exited abnormally. +fn childExitedAbnormally( + self: *Surface, + info: apprt.surface.Message.ChildExited, +) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", switch (self.io.backend) { + .exec => |*exec| exec.subprocess.args, + }); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.os.tag.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{info.exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + +/// Called when the terminal detects there is a password input prompt. +fn passwordInput(self: *Surface, v: bool) !void { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // If our password input state is unchanged then we don't + // waste time doing anything more. + const old = self.io.terminal.flags.password_input; + if (old == v) return; + + self.io.terminal.flags.password_input = v; + } + + // Notify our apprt so it can do whatever it wants. + _ = self.rt_app.performAction( + .{ .surface = self }, + .secure_input, + if (v) .on else .off, + ) catch |err| { + // We ignore this error because we don't want to fail this + // entire operation just because the apprt failed to set + // the secure input state. + log.warn("apprt failed to set secure input state err={}", .{err}); + }; + + try self.queueRender(); +} + +fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + // IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE + // to access anything other than values that never change on the surface. + // The surface is guaranteed to be valid for the lifetime of the search + // thread. + const self: *Surface = @ptrCast(@alignCast(ud.?)); + self.searchCallback_(event) catch |err| { + log.warn("error in search callback err={}", .{err}); + }; +} + +fn searchCallback_( + self: *Surface, + event: terminal.search.Thread.Event, +) !void { + // NOTE: This runs on the search thread. + + switch (event) { + .viewport_matches => |matches_unowned| { + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const matches = try alloc.dupe(terminal.highlight.Flattened, matches_unowned); + for (matches) |*m| m.* = try m.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = arena, + .matches = matches, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + + // Reset the selected index + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + + // When we quit, tell our renderer to reset any search state. + .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = .init(self.alloc), + .matches = &.{}, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); + }, + + // Unhandled, so far. + .complete => {}, + } +} + +/// Call this when modifiers change. This is safe to call even if modifiers +/// match the previous state. +/// +/// This is not publicly exported because modifier changes happen implicitly +/// on mouse callbacks, key callbacks, etc. +/// +/// The renderer state mutex MUST NOT be held. +fn modsChanged(self: *Surface, mods: input.Mods) void { + // The only place we keep track of mods currently is on the mouse. + if (!self.mouse.mods.equal(mods)) { + // The mouse mods only contain binding modifiers since we don't + // want caps/num lock or sided modifiers to affect the mouse. + self.mouse.mods = mods.binding(); + + // We also need to update the renderer so it knows if it should + // highlight links. Additionally, mark the screen as dirty so + // that the highlight state of all links is properly updated. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.renderer_state.mouse.mods = self.mouseModsWithCapture(self.mouse.mods); + + // We use the clear screen dirty flag to force a rebuild of all + // rows because changing mouse mods can affect the highlight state + // of a link. If there is no link this seems very wasteful but + // its really only one frame so it's not so bad. + self.renderer_state.terminal.flags.dirty.clear = true; + } + + self.queueRender() catch |err| { + // Not a big deal if this fails. + log.warn("failed to notify renderer of mods change err={}", .{err}); + }; + } +} + +/// Call this whenever the mouse moves or mods changed. The time +/// at which this is called may matter for the correctness of other +/// mouse events (see cursorPosCallback) but this is shared logic +/// for multiple events. +fn mouseRefreshLinks( + self: *Surface, + pos: apprt.CursorPos, + pos_vp: terminal.point.Coordinate, + over_link: bool, +) !void { + // If the position is outside our viewport, do nothing + if (pos.x < 0 or pos.y < 0) return; + + // Update the last point that we checked for links so we don't + // recheck if the mouse moves some pixels to the same point. + self.mouse.link_point = pos_vp; + + // We use an arena for everything below to make things easy to clean up. + // In the case we don't do any allocs this is very cheap to setup + // (effectively just struct init). + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Get our link at the current position. This returns null if there + // isn't a link OR if we shouldn't be showing links for some reason + // (see further comments for cases). + const link_: ?apprt.action.MouseOverLink, const preview: bool = link: { + // If we clicked and our mouse moved cells then we never + // highlight links until the mouse is unclicked. This follows + // standard macOS and Linux behavior where a click and drag cancels + // mouse actions. + const left_idx = @intFromEnum(input.MouseButton.left); + if (self.mouse.click_state[left_idx] == .press) click: { + const pin = self.mouse.left_click_pin orelse break :click; + const click_pt = self.io.terminal.screens.active.pages.pointFromPin( + .viewport, + pin.*, + ) orelse break :click; + + if (!click_pt.coord().eql(pos_vp)) { + log.debug("mouse moved while left click held, ignoring link hover", .{}); + break :link .{ null, false }; + } + } + + const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false }; + switch (link.action) { + .open => { + const str = try self.io.terminal.screens.active.selectionString(alloc, .{ + .sel = link.selection, + .trim = false, + }); + break :link .{ + .{ .url = str }, + self.config.link_previews == .true, + }; + }, + + ._open_osc8 => { + // Show the URL in the status bar + const pin = link.selection.start(); + const uri = self.osc8URI(pin) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); + break :link .{ null, false }; + }; + break :link .{ + .{ .url = try alloc.dupeZ(u8, uri) }, + self.config.link_previews != .false, + }; + }, + } + }; + + // If we found a link, setup our internal state and notify the + // apprt so it can highlight it. + if (link_) |link| { + self.renderer_state.mouse.point = pos_vp; + self.mouse.over_link = true; + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + .pointer, + ); + + if (preview) { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + link, + ); + } + + try self.queueRender(); + return; + } + + // No link, if we're previously over a link then we need to clear + // the over-link apprt state. + if (over_link) { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + return; + } +} + +/// Called when our renderer health state changes. +fn updateRendererHealth(self: *Surface, health: rendererpkg.Health) void { + log.warn("renderer health status change status={}", .{health}); + _ = self.rt_app.performAction( + .{ .surface = self }, + .renderer_health, + health, + ) catch |err| { + log.warn("failed to notify app of renderer health change err={}", .{err}); + }; +} + +/// Called when the scrollbar state changes. +fn updateScrollbar(self: *Surface, scrollbar: terminal.Scrollbar) void { + _ = self.rt_app.performAction( + .{ .surface = self }, + .scrollbar, + scrollbar, + ) catch |err| { + log.warn("failed to notify app of scrollbar change err={}", .{err}); + }; +} + +/// This should be called anytime `config_conditional_state` changes +/// so that the apprt can reload the configuration. +fn notifyConfigConditionalState(self: *Surface) void { + _ = self.rt_app.performAction( + .{ .surface = self }, + .reload_config, + .{ .soft = true }, + ) catch |err| { + log.warn("failed to notify app of config state change err={}", .{err}); + }; +} + +/// Update our configuration at runtime. This can be called by the apprt +/// to set a surface-specific configuration that differs from the app +/// or other surfaces. +pub fn updateConfig( + self: *Surface, + original: *const configpkg.Config, +) !void { + // Apply our conditional state. If we fail to apply the conditional state + // then we log and attempt to move forward with the old config. + var config_: ?configpkg.Config = original.changeConditionalState( + self.config_conditional_state, + ) catch |err| err: { + log.warn("failed to apply conditional state to config err={}", .{err}); + break :err null; + }; + defer if (config_) |*c| c.deinit(); + + // We want a config pointer for everything so we get that either + // based on our conditional state or the original config. + const config: *const configpkg.Config = if (config_) |*c| c else original; + + // Update our new derived config immediately + const derived = DerivedConfig.init(self.alloc, config) catch |err| { + // If the derivation fails then we just log and return. We don't + // hard fail in this case because we don't want to error the surface + // when config fails we just want to keep using the old config. + log.err("error updating configuration err={}", .{err}); + return; + }; + self.config.deinit(); + self.config = derived; + + // If our mouse is hidden but we disabled mouse hiding, then show it again. + if (!self.config.mouse_hide_while_typing and self.mouse.hidden) { + self.showMouse(); + } + + // If we are in the middle of a key sequence, clear it. + self.endKeySequence(.drop, .free); + + // Deactivate all key tables since they may have changed. Importantly, + // we store pointers into the config as part of our table stack so + // we can't keep them active across config changes. But this behavior + // also matches key sequences. + _ = self.deactivateAllKeyTables() catch |err| { + log.warn("failed to deactivate key tables err={}", .{err}); + }; + + // Before sending any other config changes, we give the renderer a new font + // grid. We could check to see if there was an actual change to the font, + // but this is easier and pretty rare so it's not a performance concern. + // + // (Calling setFontSize builds and sends a new font grid to the renderer.) + try self.setFontSize(font_size: { + // If we have manually adjusted the font size, keep it that way. + if (self.font_size_adjusted) { + log.info("font size manually adjusted, preserving previous size on config reload", .{}); + break :font_size self.font_size; + } + + // If we haven't, then we update to the configured font size. + // This allows config changes to update the font size. We used to + // never do this but it was a common source of confusion and people + // assumed that Ghostty was broken! This logic makes more sense. + var size = self.font_size; + size.points = std.math.clamp(config.@"font-size", 1.0, 255.0); + break :font_size size; + }); + + // We need to store our configs in a heap-allocated pointer so that + // our messages aren't huge. + var renderer_message = try rendererpkg.Message.initChangeConfig(self.alloc, config); + errdefer renderer_message.deinit(); + var termio_config_ptr = try self.alloc.create(termio.Termio.DerivedConfig); + errdefer self.alloc.destroy(termio_config_ptr); + termio_config_ptr.* = try termio.Termio.DerivedConfig.init(self.alloc, config); + errdefer termio_config_ptr.deinit(); + + _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); + self.queueIo(.{ + .change_config = .{ + .alloc = self.alloc, + .ptr = termio_config_ptr, + }, + }, .unlocked); + + // With mailbox messages sent, we have to wake them up so they process it. + self.queueRender() catch |err| { + log.warn("failed to notify renderer of config change err={}", .{err}); + }; + + // If we have a title set then we update our window to have the + // newly configured title. + if (config.title) |title| _ = try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + + // Notify the window + _ = try self.rt_app.performAction( + .{ .surface = self }, + .config_change, + .{ .config = config }, + ); +} + +const InitialSizeError = error{ + ContentScaleUnavailable, + AppActionFailed, +}; + +/// Recalculate the initial size of the window based on the +/// configuration and invoke the apprt `initial_size` action if +/// necessary. +fn recomputeInitialSize( + self: *Surface, +) InitialSizeError!void { + // Both width and height must be set for this to work, as + // documented on the config options. + if (self.config.window_height <= 0 or + self.config.window_width <= 0) return; + + const scale = self.rt_surface.getContentScale() catch + return error.ContentScaleUnavailable; + const height = @max( + self.config.window_height, + min_window_height_cells, + ) * self.size.cell.height; + const width = @max( + self.config.window_width, + min_window_width_cells, + ) * self.size.cell.width; + const width_f32: f32 = @floatFromInt(width); + const height_f32: f32 = @floatFromInt(height); + + // The final values are affected by content scale and we need to + // account for the padding so we get the exact correct grid size. + const final_width: u32 = + @as(u32, @intFromFloat(@ceil(width_f32 / scale.x))) + + self.size.padding.left + + self.size.padding.right; + const final_height: u32 = + @as(u32, @intFromFloat(@ceil(height_f32 / scale.y))) + + self.size.padding.top + + self.size.padding.bottom; + + _ = self.rt_app.performAction( + .{ .surface = self }, + .initial_size, + .{ .width = final_width, .height = final_height }, + ) catch return error.AppActionFailed; +} + +/// Represents text read from the terminal and some metadata about it +/// that is often useful to apprts. +pub const Text = struct { + /// The text that was read from the terminal. + text: [:0]const u8, + + /// The viewport information about this text, if it is visible in + /// the viewport. + viewport: ?Viewport = null, + + pub const Viewport = struct { + /// The top-left corner of the selection in pixels within the viewport. + tl_px_x: f64, + tl_px_y: f64, + + /// The linear offset of the start of the selection and the length. + /// This is "linear" in the sense that it is the offset in the + /// flattened viewport as a single array of text. + /// + /// Note: these values are currently wrong if there is a partially + /// visible selection in the viewport (i.e. the top-left or + /// bottom-right of the selection is outside the viewport). But the + /// apprt usecase we have right now doesn't require these to be + /// correct so... let's fix this later. The wrong values will always + /// be within the text bounds so we aren't risking an overflow. + offset_start: u32, + offset_len: u32, + }; + + pub fn deinit(self: *Text, alloc: Allocator) void { + alloc.free(self.text); + } +}; + +/// Grab the value of text at the given selection point. Note that the +/// selection structure is used as a way to determine the area of the +/// screen to read from, it doesn't have to match the user's current +/// selection state. +/// +/// The returned value contains allocated data and must be deinitialized. +pub fn dumpText( + self: *Surface, + alloc: Allocator, + sel: terminal.Selection, +) !Text { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return try self.dumpTextLocked(alloc, sel); +} + +/// Same as `dumpText` but assumes the renderer state mutex is already +/// held. +pub fn dumpTextLocked( + self: *Surface, + alloc: Allocator, + sel: terminal.Selection, +) !Text { + // Read out the text + const text = try self.io.terminal.screens.active.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + errdefer alloc.free(text); + + // Calculate our viewport info if we can. + const vp: ?Text.Viewport = viewport: { + // If our bottom right pin is before the viewport, then we can't + // possibly have this text be within the viewport. + const vp_tl_pin = self.io.terminal.screens.active.pages.getTopLeft(.viewport); + const br_pin = sel.bottomRight(self.io.terminal.screens.active); + if (br_pin.before(vp_tl_pin)) break :viewport null; + + // If our top-left pin is after the viewport, then we can't possibly + // have this text be within the viewport. + const vp_br_pin = self.io.terminal.screens.active.pages.getBottomRight(.viewport) orelse { + // I don't think this is possible but I don't want to crash on + // that assertion so let's just break out... + log.warn("viewport bottom-right pin not found, bug?", .{}); + break :viewport null; + }; + const tl_pin = sel.topLeft(self.io.terminal.screens.active); + if (vp_br_pin.before(tl_pin)) break :viewport null; + + // We established that our top-left somewhere before the viewport + // bottom-right and that our bottom-right is somewhere after + // the top-left. This means that at least some portion of our + // selection is within the viewport. + + // Our top-left point. If it doesn't exist in the viewport it must + // be before and we can return (0,0). + const tl_pt: terminal.Point = self.io.terminal.screens.active.pages.pointFromPin( + .viewport, + tl_pin, + ) orelse tl: { + if (comptime std.debug.runtime_safety) { + assert(tl_pin.before(vp_tl_pin)); + } + + break :tl .{ .viewport = .{} }; + }; + + // Our bottom-right point. If it doesn't exist in the viewport + // it must be the bottom-right of the viewport. + const br_pt = self.io.terminal.screens.active.pages.pointFromPin( + .viewport, + br_pin, + ) orelse br: { + if (comptime std.debug.runtime_safety) { + assert(vp_br_pin.before(br_pin)); + } + + break :br self.io.terminal.screens.active.pages.pointFromPin( + .viewport, + vp_br_pin, + ).?; + }; + + const tl_coord = tl_pt.coord(); + const br_coord = br_pt.coord(); + + // Our sizes are all scaled so we need to send the unscaled values back. + const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; + const x: f64 = x: { + // Simple x * cell width gives the left + var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width); + + // Add padding + x += @floatFromInt(self.size.padding.left); + + // Scale + x /= content_scale.x; + + break :x x; + }; + const y: f64 = y: { + // Simple y * cell height gives the top + var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height); + + // We want the text baseline + y += @floatFromInt(self.size.cell.height); + y -= @floatFromInt(self.font_metrics.cell_baseline); + + // Add padding + y += @floatFromInt(self.size.padding.top); + + // Scale + y /= content_scale.y; + + break :y y; + }; + + // Utilize viewport sizing to convert to offsets + const start = tl_coord.y * self.io.terminal.screens.active.pages.cols + tl_coord.x; + const end = br_coord.y * self.io.terminal.screens.active.pages.cols + br_coord.x; + + break :viewport .{ + .tl_px_x = x, + .tl_px_y = y, + .offset_start = start, + .offset_len = end - start, + }; + }; + + return .{ + .text = text, + .viewport = vp, + }; +} + +/// Returns true if the terminal has a selection. +pub fn hasSelection(self: *const Surface) bool { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return self.io.terminal.screens.active.selection != null; +} + +/// Returns the selected text. This is allocated. +pub fn selectionString(self: *Surface, alloc: Allocator) !?[:0]const u8 { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selection orelse return null; + return try self.io.terminal.screens.active.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); +} + +/// Returns the pwd of the terminal, if any. This is always copied because +/// the pwd can change at any point from termio. If we are calling from the IO +/// thread you should just check the terminal directly. +pub fn pwd( + self: *const Surface, + alloc: Allocator, +) Allocator.Error!?[]const u8 { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const terminal_pwd = self.io.terminal.getPwd() orelse return null; + return try alloc.dupe(u8, terminal_pwd); +} + +/// Resolves a relative file path to an absolute path using the terminal's pwd. +fn resolvePathForOpening( + self: *Surface, + path: []const u8, +) Allocator.Error!?[]const u8 { + if (!std.fs.path.isAbsolute(path)) { + const terminal_pwd = self.io.terminal.getPwd() orelse { + return null; + }; + + const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); + + std.fs.accessAbsolute(resolved, .{}) catch { + self.alloc.free(resolved); + return null; + }; + + return resolved; + } + + return null; +} + +/// Returns the x/y coordinate of where the IME (Input Method Editor) +/// keyboard should be rendered. +pub fn imePoint(self: *const Surface) apprt.IMEPos { + self.renderer_state.mutex.lock(); + const cursor = self.renderer_state.terminal.screens.active.cursor; + const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; + self.renderer_state.mutex.unlock(); + + // TODO: need to handle when scrolling and the cursor is not + // in the visible portion of the screen. + + // Our sizes are all scaled so we need to send the unscaled values back. + const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; + + const x: f64 = x: { + // Simple x * cell width gives the top-left corner, then add padding offset + var x: f64 = @floatFromInt(cursor.x * self.size.cell.width + self.size.padding.left); + + // We want the midpoint + x += @as(f64, @floatFromInt(self.size.cell.width)) / 2; + + // And scale it + x /= content_scale.x; + + break :x x; + }; + + const y: f64 = y: { + // Simple y * cell height gives the top-left corner, then add padding offset + var y: f64 = @floatFromInt(cursor.y * self.size.cell.height + self.size.padding.top); + + // We want the bottom + y += @floatFromInt(self.size.cell.height); + + // And scale it + y /= content_scale.y; + + break :y y; + }; + + // Our height for now is always just the cell height because our preedit + // rendering only renders in a single line. + const height: f64 = height: { + var height: f64 = @floatFromInt(self.size.cell.height); + height /= content_scale.y; + break :height height; + }; + const width: f64 = width: { + var width: f64 = @floatFromInt(preedit_width * self.size.cell.width); + + // Our max width is the remaining screen width after the cursor. + // We don't have to deal with wrapping because the preedit doesn't + // wrap right now. + const screen_width: f64 = @floatFromInt(self.size.terminal().width); + const x_offset: f64 = @floatFromInt((cursor.x + 1) * self.size.cell.width); + const max = screen_width - x_offset; + width = @min(width, max); + + // Note: we don't apply content scale here because it looks like + // for some reason in macOS its already scaled. I'm not sure why + // that is so I'm going to just leave this comment here so its known + // that I left this out on purpose pending more investigation. + + break :width width; + }; + + return .{ + .x = x, + .y = y, + .width = width, + .height = height, + }; +} + +fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { + if (self.config.clipboard_write == .deny) { + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + return; + } + + const dec = std.base64.standard.Decoder; + + // Build buffer + const size = dec.calcSizeForSlice(data) catch |err| switch (err) { + error.InvalidPadding => { + log.info("application sent invalid base64 data for OSC 52", .{}); + return; + }, + + // Should not be reachable but don't want to risk it. + else => return, + }; + var buf = try self.alloc.allocSentinel(u8, size, 0); + defer self.alloc.free(buf); + buf[buf.len] = 0; + + // Decode + dec.decode(buf, data) catch |err| switch (err) { + // Ignore this. It is possible to actually have valid data and + // get this error, so we allow it. + error.InvalidPadding => {}, + + else => { + log.info("application sent invalid base64 data for OSC 52", .{}); + return; + }, + }; + assert(buf[buf.len] == 0); + + // When clipboard-write is "ask" a prompt is displayed to the user asking + // them to confirm the clipboard access. Each app runtime handles this + // differently. + const confirm = self.config.clipboard_write == .ask; + self.rt_surface.setClipboard(loc, &.{.{ + .mime = "text/plain", + .data = buf, + }}, confirm) catch |err| { + log.err("error setting clipboard string err={}", .{err}); + return; + }; +} + +fn copySelectionToClipboards( + self: *Surface, + sel: terminal.Selection, + clipboards: []const apprt.Clipboard, + format: input.Binding.Action.CopyToClipboard, +) !void { + // Create an arena to simplify memory management here. + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // The options we'll use for all formatting. We'll just override the + // emit format. + const opts: terminal.formatter.Options = .{ + .emit = .plain, // We'll override this below + .unwrap = true, + .trim = self.config.clipboard_trim_trailing_spaces, + .codepoint_map = self.config.clipboard_codepoint_map.map.list, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }; + + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var aw: std.Io.Writer.Allocating = .init(alloc); + var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + switch (format) { + .plain => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .vt => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .vt; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to VT format since it contains + // escape sequences that should be preserved as-is + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .html => { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .html; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format since HTML + // has its own character encoding and entity system + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .mixed => { + // First, generate plain text with codepoint mappings applied + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + + assert(aw.written().len == 0); + // Second, generate HTML without codepoint mappings + formatter = .init(self.io.terminal.screens.active, opts: { + var copy = opts; + copy.emit = .html; + + // We purposely don't emit background/foreground for mixed + // mode because the HTML contents is often used for rich text + // input and with trimmed spaces it looks pretty bad. + copy.background = null; + copy.foreground = null; + + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + } + + assert(contents.items.len > 0); + for (clipboards) |clipboard| self.rt_surface.setClipboard( + clipboard, + contents.items, + false, + ) catch |err| { + log.err( + "error setting clipboard string clipboard={} err={}", + .{ clipboard, err }, + ); + }; +} + +/// Set the selection contents. +/// +/// This must be called with the renderer mutex held. +fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { + const prev_ = self.io.terminal.screens.active.selection; + try self.io.terminal.screens.active.select(sel_); + + // If copy on select is false then exit early. + if (self.config.copy_on_select == .false) return; + + // Set our selection clipboard. If the selection is cleared we do not + // clear the clipboard. If the selection is set, we only set the clipboard + // again if it changed, since setting the clipboard can be an expensive + // operation. + const sel = sel_ orelse return; + if (prev_) |prev| if (sel.eql(prev)) return; + + switch (self.config.copy_on_select) { + .false => unreachable, // handled above with an early exit + + // Both standard and selection clipboards are set. + .clipboard => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), + + // The selection clipboard is set if supported, otherwise the standard. + .true => { + const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection)) + .selection + else + .standard; + try self.copySelectionToClipboards( + sel, + &.{clipboard}, + .mixed, + ); + }, + } +} + +/// Change the cell size for the terminal grid. This can happen as +/// a result of changing the font size at runtime. +fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { + // Update our cell size within our size struct + self.size.cell = size; + self.balancePaddingIfNeeded(); + + // Notify the terminal + self.queueIo(.{ .resize = self.size }, .unlocked); + + // Update our terminal default size if necessary. + self.recomputeInitialSize() catch |err| { + // We don't treat this as a fatal error because not setting + // an initial size shouldn't stop our terminal from working. + log.warn("unable to recompute initial window size: {}", .{err}); + }; + + // Notify the window + _ = try self.rt_app.performAction( + .{ .surface = self }, + .cell_size, + .{ .width = size.width, .height = size.height }, + ); +} + +/// Change the font size. +/// +/// This can only be called from the main thread. +pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) !void { + log.debug("set font size size={}", .{size.points}); + + // Update our font size so future changes work + self.font_size = size; + + // We need to build up a new font stack for this font size. + const font_grid_key, const font_grid = try self.app.font_grid_set.ref( + &self.config.font, + self.font_size, + ); + errdefer self.app.font_grid_set.deref(font_grid_key); + + // Set our cell size + try self.setCellSize(.{ + .width = font_grid.metrics.cell_width, + .height = font_grid.metrics.cell_height, + }); + + // Notify our render thread of the new font stack. The renderer + // MUST accept the new font grid and deref the old. + _ = self.renderer_thread.mailbox.push(.{ + .font_grid = .{ + .grid = font_grid, + .set = &self.app.font_grid_set, + .old_key = self.font_grid_key, + .new_key = font_grid_key, + }, + }, .{ .forever = {} }); + + // Once we've sent the key we can replace our key + self.font_grid_key = font_grid_key; + self.font_metrics = font_grid.metrics; + + // Schedule render which also drains our mailbox + self.queueRender() catch unreachable; +} + +/// This queues a render operation with the renderer thread. The render +/// isn't guaranteed to happen immediately but it will happen as soon as +/// practical. +fn queueRender(self: *Surface) !void { + try self.renderer_thread.wakeup.notify(); +} + +pub fn sizeCallback(self: *Surface, size: apprt.SurfaceSize) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + const new_screen_size: rendererpkg.ScreenSize = .{ + .width = size.width, + .height = size.height, + }; + + // Update our screen size, but only if it actually changed. And if + // the screen size didn't change, then our grid size could not have + // changed, so we just return. + if (self.size.screen.equals(new_screen_size)) return; + + try self.resize(new_screen_size); +} + +fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { + // Save our screen size + self.size.screen = size; + self.balancePaddingIfNeeded(); + + // Recalculate our grid size. Because Ghostty supports fluid resizing, + // its possible the grid doesn't change at all even if the screen size changes. + // We have to update the IO thread no matter what because we send + // pixel-level sizing to the subprocess. + const grid_size = self.size.grid(); + if (grid_size.columns < 5 and (self.size.padding.left > 0 or self.size.padding.right > 0)) { + log.warn("WARNING: very small terminal grid detected with padding " ++ + "set. Is your padding reasonable?", .{}); + } + if (grid_size.rows < 2 and (self.size.padding.top > 0 or self.size.padding.bottom > 0)) { + log.warn("WARNING: very small terminal grid detected with padding " ++ + "set. Is your padding reasonable?", .{}); + } + + // Mail the IO thread + self.queueIo(.{ .resize = self.size }, .unlocked); +} + +/// Recalculate the balanced padding if needed. +fn balancePaddingIfNeeded(self: *Surface) void { + if (self.config.window_padding_balance == .false) return; + const content_scale = try self.rt_surface.getContentScale(); + const x_dpi = content_scale.x * font.face.default_dpi; + const y_dpi = content_scale.y * font.face.default_dpi; + self.size.balancePadding(self.config.scaledPadding(x_dpi, y_dpi), self.config.window_padding_balance); +} + +/// Called to set the preedit state for character input. Preedit is used +/// with dead key states, for example, when typing an accent character. +/// This should be called with null to reset the preedit state. +/// +/// The core surface will NOT reset the preedit state on charCallback or +/// keyCallback and we rely completely on the apprt implementation to track +/// the preedit state correctly. +/// +/// The preedit input must be UTF-8 encoded. +pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { + // log.debug("text preeditCallback value={any}", .{preedit_}); + + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // We clear our selection when ANY OF: + // 1. We have an existing preedit + // 2. We have preedit text + if (self.renderer_state.preedit != null or + preedit_ != null) + { + if (self.config.selection_clear_on_typing) { + self.setSelection(null) catch {}; + } + } + + // We always clear our prior preedit + if (self.renderer_state.preedit) |p| { + self.alloc.free(p.codepoints); + self.renderer_state.preedit = null; + } + + // Mark preedit dirty flag + self.io.terminal.flags.dirty.preedit = true; + + // If we have no text, we're done. We queue a render in case we cleared + // a prior preedit (likely). + const text = preedit_ orelse { + try self.queueRender(); + return; + }; + + // We convert the UTF-8 text to codepoints. + const view = try std.unicode.Utf8View.init(text); + var it = view.iterator(); + + // Allocate the codepoints slice + const Codepoint = rendererpkg.State.Preedit.Codepoint; + var codepoints: std.ArrayListUnmanaged(Codepoint) = .{}; + defer codepoints.deinit(self.alloc); + while (it.nextCodepoint()) |cp| { + const width: usize = @intCast(unicode.table.get(cp).width); + + // I've never seen a preedit text with a zero-width character. In + // theory its possible but we can't really handle it right now. + // Let's just ignore it. + if (width <= 0) continue; + + try codepoints.append( + self.alloc, + .{ .codepoint = cp, .wide = width >= 2 }, + ); + } + + // If we have no codepoints, then we're done. + if (codepoints.items.len == 0) { + try self.queueRender(); + return; + } + + self.renderer_state.preedit = .{ + .codepoints = try codepoints.toOwnedSlice(self.alloc), + }; + try self.queueRender(); +} + +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +/// +/// Note that this function does not check if the binding itself +/// is performable, only if the key event would trigger a binding. +/// If a performable binding is found and the event is not performable, +/// then Ghosty will act as though the binding does not exist. +pub fn keyEventIsBinding( + self: *Surface, + event_orig: input.KeyEvent, +) ?input.Binding.Flags { + // Apply key remappings for consistency with keyCallback + var event = event_orig; + if (self.config.key_remaps.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(event_orig.mods); + } + + switch (event.action) { + .release => return null, + .press, .repeat => {}, + } + + // Look up our entry + const entry: input.Binding.Set.Entry = entry: { + // If we're in a sequence, check the sequence set + if (self.keyboard.sequence_set) |set| { + break :entry set.getEvent(event) orelse return null; + } + + // Check active key tables (inner-most to outer-most) + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + if (table_items[rev_i].set.getEvent(event)) |entry| { + break :entry entry; + } + } + + // Check the root set + break :entry self.config.keybind.set.getEvent(event) orelse return null; + }; + + // Return flags based on the + return switch (entry.value_ptr.*) { + .leader => .{}, + inline .leaf, .leaf_chained => |v| v.flags, + }; +} + +/// Called for any key events. This handles keybindings, encoding and +/// sending to the terminal, etc. +pub fn keyCallback( + self: *Surface, + event_orig: input.KeyEvent, +) !InputEffect { + // log.warn("text keyCallback event={}", .{event_orig}); + + // Apply key remappings to transform modifiers before any processing. + // This allows users to remap modifier keys at the app level. + var event = event_orig; + if (self.config.key_remaps.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(event_orig.mods); + } + + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // Setup our inspector event if we have an inspector. + var insp_ev: ?inspectorpkg.KeyEvent = if (self.inspector != null) ev: { + var copy = event; + copy.utf8 = ""; + if (event.utf8.len > 0) copy.utf8 = try self.alloc.dupe(u8, event.utf8); + break :ev .{ .event = copy }; + } else null; + + // When we're done processing, we always want to add the event to + // the inspector. + defer if (insp_ev) |ev| ev: { + // We have to check for the inspector again because our keybinding + // might close it. + const insp = self.inspector orelse { + ev.deinit(self.alloc); + break :ev; + }; + + if (insp.recordKeyEvent(self.alloc, ev)) { + self.queueRender() catch {}; + } else |err| { + log.warn("error adding key event to inspector err={}", .{err}); + } + }; + + // Handle keybindings first. We need to handle this on all events + // (press, repeat, release) because a press may perform a binding but + // a release should not encode if we consumed the press. + if (try self.maybeHandleBinding( + event, + if (insp_ev) |*ev| ev else null, + )) |v| return v; + // If we allow KAM and KAM is enabled then we do nothing. + if (self.config.vt_kam_allowed) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; + } + + // If this input event has text, then we hide the mouse if configured. + // We only do this on pressed events to avoid hiding the mouse when we + // change focus due to a keybinding (i.e. switching tabs). + if (self.config.mouse_hide_while_typing and + event.action == .press and + !self.mouse.hidden and + event.utf8.len > 0) + { + self.hideMouse(); + } + + // If our mouse modifiers change we may need to change our + // link highlight state. + if (!self.mouse.mods.equal(event.mods)) mouse_mods: { + // Update our modifiers, this will update mouse mods too + self.modsChanged(event.mods); + + // We only refresh links if + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false))) + { + // Refresh our link state + const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.mouseRefreshLinks( + pos, + self.posToViewport(pos.x, pos.y), + self.mouse.over_link, + ) catch |err| { + log.warn("failed to refresh links err={}", .{err}); + break :mouse_mods; + }; + } else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) { + // If we have mouse reports on and we don't have shift pressed, we reset state + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + } + } + + // Process the cursor state logic. This will update the cursor shape if + // needed, depending on the key state. + if ((SurfaceMouse{ + .physical_key = event.key, + .mouse_event = self.io.terminal.flags.mouse_event, + .mouse_shape = self.io.terminal.mouse_shape, + .mods = self.mouse.mods, + .over_link = self.mouse.over_link, + .hidden = self.mouse.hidden, + }).keyToMouseShape()) |shape| _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + shape, + ); + + // We've processed a key event that produced some data so we want to + // track the last pressed key. + self.pressed_key = event: { + // We need to unset the allocated fields that will become invalid + var copy = event; + copy.utf8 = ""; + + // If we have a previous pressed key and we're releasing it + // then we set it to invalid to prevent repeating the release event. + if (event.action == .release) { + // if we didn't have a previous event and this is a release + // event then we just want to set it to null. + const prev = self.pressed_key orelse break :event null; + if (prev.key == copy.key) copy.key = .unidentified; + } + + // If our key is invalid and we have no mods, then we're done! + // This helps catch the state that we naturally released all keys. + if (copy.key == .unidentified and copy.mods.empty()) break :event null; + + break :event copy; + }; + + // Encode and send our key. If we didn't encode anything, then we + // return the effect as ignored. + if (try self.encodeKey( + event, + if (insp_ev) |*ev| ev else null, + )) |write_req| { + // If our process is exited and we press a key that results in + // an encoded value, we close the surface. We want to eventually + // move this behavior to the apprt probably. + if (self.child_exited) { + self.close(); + return .closed; + } + + errdefer write_req.deinit(); + self.queueIo(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + } else { + // No valid request means that we didn't encode anything. + return .ignored; + } + + // If our event is any keypress that isn't a modifier and we generated + // some data to send to the pty, then we move the viewport down to the + // bottom. We also clear the selection for any key other then modifiers. + if (!event.key.modifier()) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + if (self.config.selection_clear_on_typing or + event.key == .escape) + { + try self.setSelection(null); + } + + if (self.config.scroll_to_bottom.keystroke) self.io.terminal.scrollViewport(.bottom); + + try self.queueRender(); + } + + return .consumed; +} + +/// Maybe handles a binding for a given event and if so returns the effect. +/// Returns null if the event is not handled in any way and processing should +/// continue. +fn maybeHandleBinding( + self: *Surface, + event: input.KeyEvent, + insp_ev: ?*inspectorpkg.KeyEvent, +) !?InputEffect { + switch (event.action) { + // Release events never trigger a binding but we need to check if + // we consumed the press event so we don't encode the release. + .release => { + if (self.keyboard.last_trigger) |last| { + if (last == event.bindingHash()) { + // We don't reset the last trigger on release because + // an apprt may send multiple release events for a single + // press event. + return .consumed; + } + } + + return null; + }, + + // Carry on processing. + .press, .repeat => {}, + } + + // Find an entry in the keybind set that matches our event. + const entry: input.Binding.Set.Entry = entry: { + // Handle key sequences first. + if (self.keyboard.sequence_set) |set| { + // Get our entry from the set for the given event. + if (set.getEvent(event)) |v| break :entry v; + + // No entry found. We need to encode everything up to this + // point and send to the pty since we're in a sequence. + + // We ignore modifiers so that nested sequences such as + // ctrl+a>ctrl+b>c work. + if (event.key.modifier()) return null; + + // If we have a catch-all of ignore, then we special case our + // invalid sequence handling to ignore it. + if (self.catchAllIsIgnore()) { + self.endKeySequence(.drop, .retain); + return .ignored; + } + + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + + return null; + } + + // No currently active sequence, move on to tables. For tables, + // we search inner-most table to outer-most. The table stack does + // NOT include the root set. + const table_items = self.keyboard.table_stack.items; + if (table_items.len > 0) { + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const table = table_items[rev_i]; + if (table.set.getEvent(event)) |v| { + // If this is a one-shot activation AND its the currently + // active table, then we deactivate it after this. + // Note: we may want to change the semantics here to + // remove this table no matter where it is in the stack, + // maybe. + if (table.once and i == 0) _ = try self.performBindingAction( + .deactivate_key_table, + ); + + break :entry v; + } + } + } + + // No table, use our default set + break :entry self.config.keybind.set.getEvent(event) orelse + return null; + }; + + // Determine if this entry has an action or if its a leader key. + const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { + .leader => |set| { + // Setup the next set we'll look at. + self.keyboard.sequence_set = set; + + // Store this event so that we can drain and encode on invalid. + // We don't need to cap this because it is naturally capped by + // the config validation. + if (try self.encodeKey(event, insp_ev)) |req| { + try self.keyboard.sequence_queued.append(self.alloc, req); + } + + // Start or continue our key sequence + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_sequence, + .{ .trigger = entry.key_ptr.* }, + ) catch |err| { + log.warn( + "failed to notify app of key sequence err={}", + .{err}, + ); + }; + + return .consumed; + }, + + inline .leaf, .leaf_chained => |leaf| leaf.generic(), + }; + + // consumed determines if the input is consumed or if we continue + // encoding the key (if we have a key to encode). + const consumed = consumed: { + // If the consumed flag is explicitly set, then we are consumed. + if (leaf.flags.consumed) break :consumed true; + + // If the global or all flag is set, we always consume. + if (leaf.flags.global or leaf.flags.all) break :consumed true; + + break :consumed false; + }; + + // We have an action, so at this point we're handling SOMETHING so + // we reset the last trigger to null. We only set this if we actually + // perform an action (below) + self.keyboard.last_trigger = null; + + // An action also always resets the sequence set. + self.keyboard.sequence_set = null; + + // Setup our actions + const actions = leaf.actionsSlice(); + + // Attempt to perform the action + log.debug("key event binding flags={} action={any}", .{ + leaf.flags, + actions, + }); + const performed = performed: { + // If this is a global or all action, then we perform it on + // the app and it applies to every surface. + if (leaf.flags.global or leaf.flags.all) { + self.app.performAllChainedAction( + self.rt_app, + actions, + ); + + // "All" actions are always performed since they are global. + break :performed true; + } + + // Perform each action. We are performed if ANY of the chained + // actions perform. + var performed: bool = false; + for (actions) |action| { + if (self.performBindingAction(action)) |v| { + performed = performed or v; + } else |err| { + log.info( + "key binding action failed action={t} err={}", + .{ action, err }, + ); + } + } + + break :performed performed; + }; + + if (performed) { + // If we performed an action and it was a closing action, + // our "self" pointer is not safe to use anymore so we need to + // just exit immediately. + for (actions) |action| if (closingAction(action)) { + log.debug("key binding is a closing binding, halting key event processing", .{}); + return .closed; + }; + + // If our action was "ignore" then we return the special input + // effect of "ignored". + for (actions) |action| if (action == .ignore) { + // If we're in a sequence, clear it. + self.endKeySequence(.drop, .retain); + + return .ignored; + }; + } + + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. + if (leaf.flags.performable and !performed) { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and flush + // any queued events. + self.endKeySequence(.flush, .retain); + + return null; + } + + // If we consume this event, then we are done. If we don't consume + // it, we processed the action but we still want to process our + // encodings, too. + if (consumed) { + // If we had queued events, we deinit them since we consumed + self.endKeySequence(.drop, .retain); + + // Store our last trigger so we don't encode the release event + self.keyboard.last_trigger = event.bindingHash(); + + if (insp_ev) |ev| { + ev.binding = self.alloc.dupe( + input.Binding.Action, + actions, + ) catch |err| binding: { + log.warn( + "error allocating binding action for inspector err={}", + .{err}, + ); + break :binding &.{}; + }; + } + return .consumed; + } + + // If we didn't perform OR we didn't consume, then we want to + // encode any queued events for a sequence. + self.endKeySequence(.flush, .retain); + + return null; +} + +fn deactivateAllKeyTables(self: *Surface) !bool { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate_all, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + + return true; +} + +/// This checks if the current keybinding sets have a catch_all binding +/// with `ignore`. This is used to determine some special input cases. +fn catchAllIsIgnore(self: *Surface) bool { + // Get our catch all + const entry: input.Binding.Set.Entry = entry: { + const trigger: input.Binding.Trigger = .{ .key = .catch_all }; + + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const entry = table_items[rev_i].set.get(trigger) orelse continue; + break :entry entry; + } + + break :entry self.config.keybind.set.get(trigger) orelse + return false; + }; + + // We have a catch-all entry, see if its an ignore + return switch (entry.value_ptr.*) { + .leader => false, + .leaf => |leaf| leaf.action == .ignore, + .leaf_chained => |leaf| chained: for (leaf.actions.items) |action| { + if (action == .ignore) break :chained true; + } else false, + }; +} + +const KeySequenceQueued = enum { flush, drop }; +const KeySequenceMemory = enum { retain, free }; + +/// End a key sequence. Safe to call if no key sequence is active. +/// +/// Action and mem determine the behavior of the queued inputs up to this +/// point. +fn endKeySequence( + self: *Surface, + action: KeySequenceQueued, + mem: KeySequenceMemory, +) void { + // Notify apprt key sequence ended + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_sequence, + .end, + ) catch |err| { + log.warn( + "failed to notify app of key sequence end err={}", + .{err}, + ); + }; + + // No matter what we clear our current sequence set. This restores + // the set we look at to the root set. + self.keyboard.sequence_set = null; + + // If we have no queued data, there is nothing else to do. + if (self.keyboard.sequence_queued.items.len == 0) return; + + // Run the proper action first + switch (action) { + .flush => for (self.keyboard.sequence_queued.items) |write_req| { + self.queueIo(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + }, + + .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), + } + + // Memory handling of the sequence after the action + switch (mem) { + .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), + .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), + } +} + +/// Encodes the key event into a write request. The write request will +/// always copy or allocate so the caller can safely free the event. +fn encodeKey( + self: *Surface, + event: input.KeyEvent, + insp_ev: ?*inspectorpkg.KeyEvent, +) !?termio.Message.WriteReq { + const write_req: termio.Message.WriteReq = req: { + // Build our encoding options, which requires the lock. + const encoding_opts = self.encodeKeyOpts(); + + // Try to write the input into a small array. This fits almost + // every scenario. Larger situations can happen due to long + // pre-edits. + var data: termio.Message.WriteReq.Small.Array = undefined; + var writer: std.Io.Writer = .fixed(&data); + if (input.key_encode.encode( + &writer, + event, + encoding_opts, + )) { + const written = writer.buffered(); + + // Special-case: we did nothing. + if (written.len == 0) return null; + + break :req .{ .small = .{ + .data = data, + .len = @intCast(written.len), + } }; + } else |err| switch (err) { + // Means we need to allocate + error.WriteFailed => {}, + } + + // We need to allocate. We allocate double the UTF-8 length + // or double the small array size, whichever is larger. That's + // a heuristic that should work. The only scenario I know while + // typing this where we don't have enough space is a long preedit, + // and in that case the size we need is exactly the UTF-8 length, + // so the double is being safe. + var alloc_writer: std.Io.Writer.Allocating = try .initCapacity( + self.alloc, + @max(event.utf8.len * 2, data.len * 2), + ); + defer alloc_writer.deinit(); + + // This results in a double allocation but this is such an unlikely + // path the performance impact is unimportant. + try input.key_encode.encode( + &alloc_writer.writer, + event, + encoding_opts, + ); + break :req try termio.Message.WriteReq.init( + self.alloc, + alloc_writer.writer.buffered(), + ); + }; + + // Copy the encoded data into the inspector event if we have one. + // We do this before the mailbox because the IO thread could + // release the memory before we get a chance to copy it. + if (insp_ev) |ev| pty: { + const slice = write_req.slice(); + const copy = self.alloc.alloc(u8, slice.len) catch |err| { + log.warn("error allocating pty data for inspector err={}", .{err}); + break :pty; + }; + errdefer self.alloc.free(copy); + @memcpy(copy, slice); + ev.pty = copy; + } + + return write_req; +} + +fn encodeKeyOpts(self: *const Surface) input.key_encode.Options { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = &self.io.terminal; + + var opts: input.key_encode.Options = .fromTerminal(t); + if (comptime builtin.os.tag != .macos) return opts; + + opts.macos_option_as_alt = self.config.macos_option_as_alt orelse detect: { + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and break out and avoid + // more expensive checks below. + if (!self.mouse.mods.alt) break :detect .false; + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); + }; + + return opts; +} + +/// Sends text as-is to the terminal without triggering any keyboard +/// protocol. This will treat the input text as if it was pasted +/// from the clipboard so the same logic will be applied. Namely, +/// if bracketed mode is on this will do a bracketed paste. Otherwise, +/// this will filter newlines to '\r'. +pub fn textCallback(self: *Surface, text: []const u8) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + try self.completeClipboardPaste(text, true); +} + +/// Callback for when the surface is fully visible or not, regardless +/// of focus state. This is used to pause rendering when the surface +/// is not visible, and also re-render when it becomes visible again. +pub fn occlusionCallback(self: *Surface, visible: bool) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + _ = self.renderer_thread.mailbox.push(.{ + .visible = visible, + }, .{ .forever = {} }); + try self.queueRender(); +} + +pub fn focusCallback(self: *Surface, focused: bool) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // Always update the app focused surface, otherwise we miss + // the first surface created. + if (focused) self.app.focusSurface(self); + + // If our focus state is unchanged we do nothing else. + if (self.focused == focused) return; + self.focused = focused; + + // Notify our render thread of the new state + _ = self.renderer_thread.mailbox.push(.{ + .focus = focused, + }, .{ .forever = {} }); + + if (!focused) unfocused: { + // If we lost focus and we have a keypress, then we want to send a key + // release event for it. Depending on the apprt, this CAN result in + // duplicate key release events, but that is better than not sending + // a key release event at all. + var pressed_key = self.pressed_key orelse break :unfocused; + self.pressed_key = null; + + // All our actions will be releases + pressed_key.action = .release; + + // Release the full key first + if (pressed_key.key != .unidentified) { + assert(self.keyCallback(pressed_key) catch |err| err: { + log.warn("error releasing key on focus loss err={}", .{err}); + break :err .ignored; + } != .closed); + } + + // Release any modifiers if set + if (pressed_key.mods.empty()) break :unfocused; + + // This is kind of nasty comptime meta programming but all we're doing + // here is going through all the modifiers and if they're set, releasing + // both the left and right sides of the modifier. This may not match + // the exact input event but it ensures a full reset. + const keys = &.{ "shift", "ctrl", "alt", "super" }; + const original_key = pressed_key.key; + inline for (keys) |key| { + if (@field(pressed_key.mods, key)) { + @field(pressed_key.mods, key) = false; + inline for (&.{ "right", "left" }) |side| { + const keyname = comptime keyname: { + break :keyname if (std.mem.eql(u8, key, "ctrl")) + "control" + else if (std.mem.eql(u8, key, "super")) + "meta" + else + key; + }; + pressed_key.key = @field(input.Key, keyname ++ "_" ++ side); + if (pressed_key.key != original_key) { + assert(self.keyCallback(pressed_key) catch |err| err: { + log.warn("error releasing key on focus loss err={}", .{err}); + break :err .ignored; + } != .closed); + } + } + } + } + } + + // Schedule render which also drains our mailbox + try self.queueRender(); + + // Whenever our focus changes we unhide the mouse. The mouse will be + // hidden again if the user starts typing. This helps alleviate some + // buggy behavior upstream in macOS with the mouse never becoming visible + // again when tabbing between programs (see #2525). + self.showMouse(); + + // Update the focus state and notify the terminal + { + self.renderer_state.mutex.lock(); + self.io.terminal.flags.focused = focused; + self.renderer_state.mutex.unlock(); + self.queueIo(.{ .focused = focused }, .unlocked); + } +} + +pub fn refreshCallback(self: *Surface) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // The point of this callback is to schedule a render, so do that. + try self.queueRender(); +} + +// The amount to scroll. This structure is always normalized so that +// negative is down, left and positive is up, right. Note that INTERNALLY, +// vertical scroll on our terminal uses positive for down (right is not +// supported by our screen since scrollback is only vertical). +const ScrollAmount = struct { + delta: isize = 0, + + pub fn direction(self: ScrollAmount) enum { down_left, up_right } { + return if (self.delta < 0) .down_left else .up_right; + } + + pub fn magnitude(self: ScrollAmount) usize { + return @abs(self.delta); + } +}; + +/// Mouse scroll event. Negative is down, left. Positive is up, right. +/// +/// "Natural scrolling" is a macOS term for inverting the scroll direction. +/// This should be handled by the apprt implementation. At this layer, +/// negative is always down, left. +pub fn scrollCallback( + self: *Surface, + xoff: f64, + yoff: f64, + scroll_mods: input.ScrollMods, +) !void { + // log.info("SCROLL: xoff={} yoff={} mods={}", .{ xoff, yoff, scroll_mods }); + + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + + const y: ScrollAmount = if (yoff == 0) .{} else y: { + // We use cell_size to determine if we have accumulated enough to trigger a scroll + const cell_size: f64 = @floatFromInt(self.size.cell.height); + + // If we have precision scroll, yoff is the number of pixels to scroll. In non-precision + // scroll, yoff is the number of wheel ticks. Some mice are capable of reporting fractional + // wheel ticks, which don't necessarily get reported as precision scrolls. We normalize all + // scroll events to pixels by multiplying the wheel tick value and the cell size. This means + // that a wheel tick of 1 results in single scroll event. + const yoff_adjusted: f64 = if (scroll_mods.precision) + yoff * self.config.mouse_scroll_multiplier.precision + else yoff_adjusted: { + // Round out the yoff to an absolute minimum of 1. macos tries to + // simulate precision scrolling with non precision events by + // ramping up the magnitude of the offsets as it detects faster + // scrolling. Single click (very slow) scrolls are reported with a + // magnitude of 0.1 which would normally require a few clicks + // before we register an actual scroll event (depending on cell + // height and the mouse_scroll_multiplier setting). + const yoff_max: f64 = if (yoff > 0) + @max(yoff, 1) + else + @min(yoff, -1); + + break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete; + }; + + // Add our previously saved pending amount to the offset to get the + // new offset value. The signs of the pending and yoff should match + // so that we move further away from zero, but we don't assert + // this because in theory a user could scroll in the opposite + // direction and undo a pending scroll. + const poff: f64 = self.mouse.pending_scroll_y + yoff_adjusted; + + // If the new offset is less than a single unit of scroll, we save + // the new pending value and do not scroll yet. + if (@abs(poff) < cell_size) { + self.mouse.pending_scroll_y = poff; + break :y .{}; + } + + // We scroll by the number of rows in the offset and save the remainder + const amount = poff / cell_size; + assert(@abs(amount) >= 1); + self.mouse.pending_scroll_y = poff - (amount * cell_size); + + // Round towards zero. + const delta: isize = @intFromFloat(@trunc(amount)); + assert(@abs(delta) >= 1); + + break :y .{ .delta = delta }; + }; + + // For detailed comments see the y calculation above. + const x: ScrollAmount = if (xoff == 0) .{} else x: { + if (!scroll_mods.precision) { + const x_delta_isize: isize = @intFromFloat(@round(xoff)); + break :x .{ .delta = x_delta_isize }; + } + + const poff: f64 = self.mouse.pending_scroll_x + xoff; + const cell_size: f64 = @floatFromInt(self.size.cell.width); + if (@abs(poff) < cell_size) { + self.mouse.pending_scroll_x = poff; + break :x .{}; + } + + const amount = poff / cell_size; + assert(@abs(amount) >= 1); + self.mouse.pending_scroll_x = poff - (amount * cell_size); + const delta: isize = @intFromFloat(@trunc(amount)); + assert(@abs(delta) >= 1); + break :x .{ .delta = delta }; + }; + + // log.info("SCROLL: delta_y={} delta_x={}", .{ y.delta, x.delta }); + + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // If we have an active mouse reporting mode, clear the selection. + // The selection can occur if the user uses the shift mod key to + // override mouse grabbing from the window. + if (self.isMouseReporting()) { + try self.setSelection(null); + } + + // If we're in alternate screen with alternate scroll enabled, then + // we convert to cursor keys. This only happens if we're: + // (1) alt screen (2) no explicit mouse reporting and (3) alt + // scroll mode enabled. + if (self.io.terminal.screens.active_key == .alternate and + self.io.terminal.flags.mouse_event == .none and + self.io.terminal.modes.get(.mouse_alternate_scroll)) + { + if (y.delta != 0) { + // When we send mouse events as cursor keys we always + // clear the selection. + try self.setSelection(null); + + const seq = if (self.io.terminal.modes.get(.cursor_keys)) seq: { + // cursor key: application mode + break :seq switch (y.direction()) { + .up_right => "\x1bOA", + .down_left => "\x1bOB", + }; + } else seq: { + // cursor key: normal mode + break :seq switch (y.direction()) { + .up_right => "\x1b[A", + .down_left => "\x1b[B", + }; + }; + for (0..y.magnitude()) |_| { + self.queueIo(.{ .write_stable = seq }, .locked); + } + } + + return; + } + + // We have mouse events, are not in an alternate scroll buffer, + // or have alternate scroll disabled. In this case, we just run + // the normal logic. + + // If we're scrolling up or down, then send a mouse event. + if (self.isMouseReporting()) { + for (0..@abs(y.delta)) |_| { + const pos = try self.rt_surface.getCursorPos(); + self.mouseReport(switch (y.direction()) { + .up_right => .four, + .down_left => .five, + }, .press, self.mouse.mods, pos); + } + + for (0..@abs(x.delta)) |_| { + const pos = try self.rt_surface.getCursorPos(); + self.mouseReport(switch (x.direction()) { + .up_right => .six, + .down_left => .seven, + }, .press, self.mouse.mods, pos); + } + + // If mouse reporting is on, we do not want to scroll the + // viewport. + return; + } + + if (y.delta != 0) { + // Modify our viewport, this requires a lock since it affects + // rendering. We have to switch signs here because our delta + // is negative down but our viewport is positive down. + self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 }); + } + } + + try self.queueRender(); +} + +/// This is called when the content scale of the surface changes. The surface +/// can then update any DPI-sensitive state. +pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // Calculate the new DPI + const x_dpi = content_scale.x * font.face.default_dpi; + const y_dpi = content_scale.y * font.face.default_dpi; + + // Update our font size which is dependent on the DPI + const size = size: { + var size = self.font_size; + size.xdpi = @intFromFloat(x_dpi); + size.ydpi = @intFromFloat(y_dpi); + break :size size; + }; + + // If our DPI didn't actually change, save a lot of work by doing nothing. + if (size.xdpi == self.font_size.xdpi and size.ydpi == self.font_size.ydpi) { + return; + } + + try self.setFontSize(size); + + // Update our padding which is dependent on DPI. We only do this for + // unbalanced padding since balanced padding is not dependent on DPI. + if (self.config.window_padding_balance == .false) { + self.size.padding = self.config.scaledPadding(x_dpi, y_dpi); + } + + // Force a resize event because the change in padding will affect + // pixel-level changes to the renderer and viewport. + try self.resize(self.size.screen); +} + +/// Returns true if mouse reporting is enabled both in the config and +/// the terminal state. +fn isMouseReporting(self: *const Surface) bool { + return self.config.mouse_reporting and + self.io.terminal.flags.mouse_event != .none; +} + +fn mouseReport( + self: *Surface, + button: ?input.MouseButton, + action: input.MouseAction, + mods: input.Mods, + pos: apprt.CursorPos, +) void { + // Mouse reporting must be enabled by both config and terminal state + assert(self.config.mouse_reporting); + assert(self.io.terminal.flags.mouse_event != .none); + + // Build our encoding options. + const encoding_opts: input.mouse_encode.Options = opts: { + // Terminal and size state. + var opts: input.mouse_encode.Options = .fromTerminal( + &self.io.terminal, + self.size, + ); + + // Whether any button is pressed at all. + opts.any_button_pressed = pressed: { + for (self.mouse.click_state) |state| { + if (state != .release) break :pressed true; + } + + break :pressed false; + }; + + // Keep track of our last reported viewport cell for event + // deduplication. + opts.last_cell = &self.mouse.event_point; + + break :opts opts; + }; + + var data: termio.Message.WriteReq.Small.Array = undefined; + var writer: std.Io.Writer = .fixed(&data); + input.mouse_encode.encode(&writer, .{ + .button = button, + .action = action, + .mods = mods, + .pos = .{ + .x = pos.x, + .y = pos.y, + }, + }, encoding_opts) catch |err| switch (err) { + error.WriteFailed => { + // This should never happen since mouse events should never + // be able to overflow the size of our small array. But if it + // does, let's log it and return. No need to crash upstreams. + // In the future we may want to fall back to allocation. + log.warn("failed to encode mouse event err={}", .{err}); + return; + }, + }; + const written = writer.buffered(); + if (written.len == 0) return; + + self.queueIo(.{ .write_small = .{ + .data = data, + .len = @intCast(written.len), + } }, .locked); +} + +/// Returns true if the shift modifier is allowed to be captured by modifier +/// events. It is up to the caller to still verify it is a situation in which +/// shift capture makes sense (i.e. left button, mouse click, etc.) +fn mouseShiftCapture(self: *const Surface, lock: bool) bool { + // Handle our never/always case where we don't need a lock. + switch (self.config.mouse_shift_capture) { + .never => return false, + .always => return true, + .false, .true => {}, + } + + if (lock) self.renderer_state.mutex.lock(); + defer if (lock) self.renderer_state.mutex.unlock(); + + // If the terminal explicitly requests it then we always allow it + // since we processed never/always at this point. + switch (self.io.terminal.flags.mouse_shift_capture) { + .false => return false, + .true => return true, + .null => {}, + } + + // Otherwise, go with the user's preference + return switch (self.config.mouse_shift_capture) { + .false => false, + .true => true, + .never, .always => unreachable, // handled earlier + }; +} + +/// Returns true if the mouse is currently captured by the terminal +/// (i.e. reporting events). +pub fn mouseCaptured(self: *Surface) bool { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + return self.io.terminal.flags.mouse_event != .none; +} + +/// Called for mouse button press/release events. This will return true +/// if the mouse event was consumed in some way (i.e. the program is capturing +/// mouse events). If the event was not consumed, then false is returned. +pub fn mouseButtonCallback( + self: *Surface, + action: input.MouseButtonState, + button: input.MouseButton, + mods: input.Mods, +) !bool { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // log.debug("mouse action={} button={} mods={}", .{ action, button, mods }); + + // If we have an inspector, we always queue a render + if (self.inspector != null) { + defer self.queueRender() catch {}; + } + + // Always record our latest mouse state + self.mouse.click_state[@intCast(@intFromEnum(button))] = action; + + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + + // Update our modifiers if they changed + self.modsChanged(mods); + + // This is set to true if the terminal is allowed to capture the shift + // modifier. Note we can do this more efficiently probably with less + // locking/unlocking but clicking isn't that frequent enough to be a + // bottleneck. + const shift_capture = self.mouseShiftCapture(true); + + // Shift-click continues the previous mouse state if we have a selection. + // cursorPosCallback will also do a mouse report so we don't need to do any + // of the logic below. + if (button == .left and action == .press) { + // We could do all the conditionals in one but I find it more + // readable as a human to break this one up. + if (mods.shift and + self.mouse.left_click_count > 0 and + !shift_capture) + extend_selection: { + // We split this conditional out on its own because this is the + // only one that requires a renderer mutex grab which is VERY + // expensive because it could block all our threads. + if (!self.hasSelection()) break :extend_selection; + + // If we are within the interval that the click would register + // an increment then we do not extend the selection. + if (std.time.Instant.now()) |now| { + const since = now.since(self.mouse.left_click_time); + if (since <= self.config.mouse_interval) { + // Click interval very short, we may be increasing + // click counts so we don't extend the selection. + break :extend_selection; + } + } else |err| { + // This is a weird behavior, I think either behavior is actually + // fine. This failure should be exceptionally rare anyways. + // My thinking here is that we can't be sure if we should extend + // the selection or not so we just don't. + log.warn("failed to get time, not extending selection err={}", .{err}); + break :extend_selection; + } + + const pos = try self.rt_surface.getCursorPos(); + try self.cursorPosCallback(pos, null); + return true; + } + } + + if (button == .left and action == .release) { + // Stop selection scrolling when releasing the left mouse button + // but only when selection scrolling is active. + if (self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .unlocked, + ); + } + + // The selection clipboard is only updated for left-click drag when + // the left button is released. This is to avoid the clipboard + // being updated on every mouse move which would be noisy. + if (self.config.copy_on_select != .false) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const prev_ = self.io.terminal.screens.active.selection; + if (prev_) |prev| { + try self.setSelection(terminal.Selection.init( + prev.start(), + prev.end(), + prev.rectangle, + )); + } + } + + // Handle link clicking. We want to do this before we do mouse + // reporting or any other mouse handling because a successfully + // clicked link will swallow the event. + if (self.mouse.over_link) { + const pos = try self.rt_surface.getCursorPos(); + if (self.processLinks(pos)) |processed| { + if (processed) return true; + } else |err| { + log.warn("error processing links err={}", .{err}); + } + } + + // Handle prompt clicking. If we released our mouse on a prompt + // and we support some kind of click events, then we need to + // move to it. + if (self.maybePromptClick()) |handled| { + if (handled) return true; + } else |err| { + log.warn("error processing prompt click err={}", .{err}); + } + } + + // Report mouse events if enabled + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.isMouseReporting()) report: { + // If we have shift-pressed and we aren't allowed to capture it, + // then we do not do a mouse report. + if (mods.shift and !shift_capture) break :report; + + // In any other mouse button scenario without shift pressed we + // clear the selection since the underlying application can handle + // that in any way (i.e. "scrolling"). + try self.setSelection(null); + + // We also set the left click count to 0 so that if mouse reporting + // is disabled in the middle of press (before release) we don't + // suddenly start selecting text. + self.mouse.left_click_count = 0; + + const pos = try self.rt_surface.getCursorPos(); + + const report_action: input.MouseAction = switch (action) { + .press => .press, + .release => .release, + }; + + self.mouseReport( + button, + report_action, + self.mouse.mods, + pos, + ); + + // If we're doing mouse reporting, we do not support any other + // selection or highlighting. + return true; + } + } + + // For left button clicks we always record some information for + // selection/highlighting purposes. + if (button == .left and action == .press) click: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + + const pos = try self.rt_surface.getCursorPos(); + const pin = pin: { + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + // Weird... our viewport x/y that we just converted isn't + // found in our pages. This is probably a bug but we don't + // want to crash in releases because its harmless. So, we + // only assert in debug mode. + if (comptime std.debug.runtime_safety) unreachable; + break :click; + }; + + break :pin try screen.pages.trackPin(pin); + }; + errdefer screen.pages.untrackPin(pin); + + // If we move our cursor too much between clicks then we reset + // the multi-click state. + if (self.mouse.left_click_count > 0) { + const max_distance: f64 = @floatFromInt(self.size.cell.width); + const distance = @sqrt( + std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) + + std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2), + ); + + if (distance > max_distance) self.mouse.left_click_count = 0; + } + + if (self.mouse.left_click_pin) |prev| { + if (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { + pin_screen.pages.untrackPin(prev); + } + self.mouse.left_click_pin = null; + } + + // Store it + self.mouse.left_click_pin = pin; + self.mouse.left_click_screen = t.screens.active_key; + self.mouse.left_click_xpos = pos.x; + self.mouse.left_click_ypos = pos.y; + + // Setup our click counter and timer + if (std.time.Instant.now()) |now| { + // If we have mouse clicks, then we check if the time elapsed + // is less than and our interval and if so, increase the count. + if (self.mouse.left_click_count > 0) { + const since = now.since(self.mouse.left_click_time); + if (since > self.config.mouse_interval) { + self.mouse.left_click_count = 0; + } + } + + self.mouse.left_click_time = now; + self.mouse.left_click_count += 1; + + // We only support up to triple-clicks. + if (self.mouse.left_click_count > 3) self.mouse.left_click_count = 1; + } else |err| { + self.mouse.left_click_count = 1; + log.err("error reading time, mouse multi-click won't work err={}", .{err}); + } + + // In all cases below, we set the selection directly rather than use + // `setSelection` because we want to avoid copying the selection + // to the selection clipboard. For left mouse clicks we only set + // the clipboard on release. + switch (self.mouse.left_click_count) { + // Single click + 1 => { + // If we have a selection, clear it. This always happens. + if (self.io.terminal.screens.active.selection != null) { + try self.io.terminal.screens.active.select(null); + try self.queueRender(); + } + }, + + // Double click, select the word under our mouse. + // First try to detect if we're clicking on a URL to select the entire URL. + 2 => { + const sel_ = sel: { + // Try link detection without requiring modifier keys + if (self.linkAtPin( + pin.*, + null, + )) |result_| { + if (result_) |result| { + break :sel result.selection; + } + } else |_| { + // Ignore any errors, likely regex errors. + } + + break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); + }; + if (sel_) |sel| { + try self.io.terminal.screens.active.select(sel); + try self.queueRender(); + } + }, + + // Triple click, select the line under our mouse + 3 => { + const sel_ = if (mods.ctrlOrSuper()) + self.io.terminal.screens.active.selectOutput(pin.*) + else + self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); + if (sel_) |sel| { + try self.io.terminal.screens.active.select(sel); + try self.queueRender(); + } + }, + + // We should be bounded by 1 to 3 + else => unreachable, + } + } + + // Middle-click pastes from our selection clipboard + if (button == .middle and action == .press) { + const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection)) + .selection + else + .standard; + _ = try self.startClipboardRequest(clipboard, .{ .paste = {} }); + } + + // Right-click down selects word for context menus. If the apprt + // doesn't implement context menus this can be a bit weird but they + // are supported by our two main apprts so we always do this. If we + // want to be careful in the future we can add a function to apprts + // that let's us know. + if (button == .right and action == .press) sel: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Get our viewport pin + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + const pos = try self.rt_surface.getCursorPos(); + const pin = pin: { + const pt_viewport = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + break :sel; + }; + + break :pin pin; + }; + + switch (self.config.right_click_action) { + .ignore => {}, + .@"context-menu" => { + // If we already have a selection and the selection contains + // where we clicked then we don't want to modify the selection. + if (self.io.terminal.screens.active.selection) |prev_sel| { + if (prev_sel.contains(screen, pin)) break :sel; + + // The selection doesn't contain our pin, so we create a new + // word selection where we clicked. + } + + // If there is a link at this position, we want to + // select the link. Otherwise, select the word. + if (try self.linkAtPos(pos)) |link| { + try self.setSelection(link.selection); + } else { + const sel = screen.selectWord( + pin, + self.config.selection_word_chars, + ) orelse break :sel; + try self.setSelection(sel); + } + try self.queueRender(); + + // Don't consume so that we show the context menu in apprt. + return false; + }, + .copy => { + if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); + } + + try self.setSelection(null); + try self.queueRender(); + }, + .@"copy-or-paste" => if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); + try self.setSelection(null); + try self.queueRender(); + } else { + // Pasting can trigger a lock grab in complete clipboard + // request so we need to unlock. + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = try self.startClipboardRequest(.standard, .paste); + + // We don't need to clear selection because we didn't have + // one to begin with. + }, + .paste => { + // Before we yield the lock, clear our selection if we have + // one. + try self.setSelection(null); + try self.queueRender(); + + // Pasting can trigger a lock grab in complete clipboard + // request so we need to unlock. + self.renderer_state.mutex.unlock(); + defer self.renderer_state.mutex.lock(); + _ = try self.startClipboardRequest(.standard, .paste); + }, + } + + // Consume the event such that the context menu is not displayed. + return true; + } + + return false; +} + +fn maybePromptClick(self: *Surface) !bool { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + const screen: *terminal.Screen = t.screens.active; + + // If our screen doesn't handle any prompt clicks, then we never + // do anything. + if (screen.semantic_prompt.click == .none) return false; + + // If cursor-click-to-move is disabled, we don't do any prompt clicking. + if (!self.config.cursor_click_to_move) return false; + + // If our cursor isn't currently at a prompt then we don't handle + // prompt clicks because we can't move if we're not in a prompt! + if (!t.cursorIsAtPrompt()) return false; + + // If we have a selection currently, then releasing the mouse + // completes the selection and we don't do prompt moving. I don't + // love this logic, I think it should be generalized to "if the + // mouse release was on a different cell than the mouse press" but + // our mouse state at the time of writing this doesn't support that. + if (screen.selection != null) return false; + + // Get the pin for our mouse click. + const pos = try self.rt_surface.getCursorPos(); + const pos_vp = self.posToViewport(pos.x, pos.y); + const click_pin: terminal.Pin = pin: { + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + // See mouseButtonCallback for explanation + if (comptime std.debug.runtime_safety) unreachable; + return false; + }; + + break :pin pin; + }; + + // Get our cursor's most current prompt. + const prompt_pin: terminal.Pin = prompt_pin: { + var it = screen.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :prompt_pin it.next() orelse { + // This shouldn't be possible because we asserted we're at + // a prompt above, so we MUST find some prompt in a left_up search. + log.warn("cursor is at prompt but no prompt found", .{}); + if (comptime std.debug.runtime_safety) unreachable; + return false; + }; + }; + + // If our mouse click is before the prompt, we don't move. + // We DO ALLOW clicks AFTER the prompt, specifically with Kitty's + // click_events=1 since we rely on the shell to validate out of + // bounds clicks. This matches Kitty's logic as best I can tell. + if (click_pin.before(prompt_pin)) return false; + + // At this point we've established: + // - Screen supports prompt clicks + // - Cursor is at a prompt + // - Click is at or below our prompt + switch (screen.semantic_prompt.click) { + // Guarded at the start of this function + .none => unreachable, + + .click_events => { + // For the event, we always send a left-click press event. + // This matches what Kitty sends. + var data: termio.Message.WriteReq.Small.Array = undefined; + const resp = try std.fmt.bufPrint( + &data, + "\x1B[<0;{d};{d}M", + .{ pos_vp.x + 1, pos_vp.y + 1 }, + ); + + // Not that noisy since this only happens on prompt clicks. + log.debug( + "sending click_events=1 event=ESC{s}", + .{resp[1..]}, + ); + + // Ask our IO thread to write the data + self.queueIo(.{ .write_small = .{ + .data = data, + .len = @intCast(resp.len), + } }, .locked); + }, + + .cl => { + const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D"; + const right_arrow = if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; + + const move = screen.promptClickMove(click_pin); + for (0..move.left) |_| { + self.queueIo( + .{ .write_stable = left_arrow }, + .locked, + ); + } + for (0..move.right) |_| { + self.queueIo( + .{ .write_stable = right_arrow }, + .locked, + ); + } + }, + } + + return true; +} + +const Link = struct { + action: input.Link.Action, + selection: terminal.Selection, +}; + +/// Returns the link at the given cursor position, if any. +/// +/// Requires the renderer mutex is held. +fn linkAtPos( + self: *Surface, + pos: apprt.CursorPos, +) !?Link { + // Convert our cursor position to a screen point. + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + const mouse_pin: terminal.Pin = mouse_pin: { + const point = self.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ .viewport = point }) orelse { + log.warn("failed to get pin for clicked point", .{}); + return null; + }; + break :mouse_pin pin; + }; + + // Get our comparison mods + const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); + + // If we have the proper modifiers set then we can check for OSC8 links. + if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + const rac = mouse_pin.rowAndCell(); + const cell = rac.cell; + if (!cell.hyperlink) break :hyperlink; + const sel = terminal.Selection.init(mouse_pin, mouse_pin, false); + return .{ .action = ._open_osc8, .selection = sel }; + } + + // Fall back to configured links + return try self.linkAtPin(mouse_pin, mouse_mods); +} + +/// Detects if a link is present at the given pin. +/// +/// If mouse mods is null then mouse mod requirements are ignored (all +/// configured links are checked). +/// +/// Requires the renderer state mutex is held. +fn linkAtPin( + self: *Surface, + mouse_pin: terminal.Pin, + mouse_mods: ?input.Mods, +) !?Link { + if (self.config.links.len == 0) return null; + + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + const line = screen.selectLine(.{ + .pin = mouse_pin, + .whitespace = null, + .semantic_prompt_boundary = false, + }) orelse return null; + + var strmap: terminal.StringMap = undefined; + self.alloc.free(try screen.selectionString(self.alloc, .{ + .sel = line, + .trim = false, + .map = &strmap, + })); + defer strmap.deinit(self.alloc); + + for (self.config.links) |link| { + // Skip highlight/mods check when mouse_mods is null (double-click mode) + if (mouse_mods) |mods| switch (link.highlight) { + .always, .hover => {}, + .always_mods, .hover_mods => |v| if (!v.equal(mods)) continue, + }; + + var it = strmap.searchIterator(link.regex); + while (true) { + var match = (try it.next()) orelse break; + defer match.deinit(); + const sel = match.selection(); + if (!sel.contains(screen, mouse_pin)) continue; + return .{ + .action = link.action, + .selection = sel, + }; + } + } + + return null; +} + +/// This returns the mouse mods to consider for link highlighting or +/// other purposes taking into account when shift is pressed for releasing +/// the mouse from capture. +/// +/// The renderer state mutex must be held. +fn mouseModsWithCapture(self: *Surface, mods: input.Mods) input.Mods { + // In any of these scenarios, whatever mods are set (even shift) + // are preserved. + if (self.io.terminal.flags.mouse_event == .none) return mods; + if (!mods.shift) return mods; + if (self.mouseShiftCapture(false)) return mods; + + // We have mouse capture, shift set, and we're not allowed to capture + // shift, so we can clear shift. + var final = mods; + final.shift = false; + return final; +} + +/// Attempt to invoke the action of any link that is under the +/// given position. +/// +/// Requires the renderer state mutex is held. +fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { + const link = try self.linkAtPos(pos) orelse return false; + switch (link.action) { + .open => { + const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{ + .sel = link.selection, + .trim = false, + }); + defer self.alloc.free(str); + + const resolved_path = try self.resolvePathForOpening(str); + defer if (resolved_path) |p| self.alloc.free(p); + + const url_to_open = resolved_path orelse str; + try self.openUrl(.{ .kind = .unknown, .url = url_to_open }); + }, + + ._open_osc8 => { + const uri = self.osc8URI(link.selection.start()) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); + return false; + }; + try self.openUrl(.{ .kind = .unknown, .url = uri }); + }, + } + + return true; +} + +fn openUrl( + self: *Surface, + action: apprt.action.OpenUrl, +) !void { + // If the apprt handles it then we're done. + if (try self.rt_app.performAction( + .{ .surface = self }, + .open_url, + action, + )) return; + + // apprt didn't handle it, fallback to our simple cross-platform + // URL opener. We log a warning because we want well-behaved + // apprts to handle this themselves. + log.warn("apprt did not handle open URL action, falling back to default opener", .{}); + try internal_os.open( + self.alloc, + action.kind, + action.url, + ); +} + +/// Return the URI for an OSC8 hyperlink at the given position or null +/// if there is no hyperlink. +fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { + _ = self; + const page = &pin.node.data; + const cell = pin.rowAndCell().cell; + const link_id = page.lookupHyperlink(cell) orelse return null; + const entry = page.hyperlink_set.get(page.memory, link_id); + return entry.uri.slice(page.memory); +} + +pub fn mousePressureCallback( + self: *Surface, + stage: input.MousePressureStage, + pressure: f64, +) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // We don't currently use the pressure value for anything. In the + // future, we could report this to applications using new mouse + // events or utilize it for some custom UI. + _ = pressure; + + // If the pressure stage is the same as what we already have do nothing + if (self.mouse.pressure_stage == stage) return; + + // Update our pressure stage. + self.mouse.pressure_stage = stage; + + // If our left mouse button is pressed and we're entering a deep + // click then we want to start a selection. We treat this as a + // word selection since that is typical macOS behavior. + const left_idx = @intFromEnum(input.MouseButton.left); + if (self.mouse.click_state[left_idx] == .press and + stage == .deep) + select: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // This should always be set in this state but we don't want + // to handle state inconsistency here. + const pin = self.mouse.left_click_pin orelse break :select; + const sel = self.io.terminal.screens.active.selectWord( + pin.*, + self.config.selection_word_chars, + ) orelse break :select; + try self.io.terminal.screens.active.select(sel); + try self.queueRender(); + } +} + +/// Cursor position callback. +/// +/// Send negative x or y values to indicate the cursor is outside the +/// viewport. The magnitude of the negative values are meaningless; +/// they are only used to indicate the cursor is outside the viewport. +/// It's important to do this to ensure hover states are cleared. +/// +/// The mods parameter is optional because some apprts do not provide +/// modifier information on cursor position events. If mods is null then +/// we'll use the last known mods. This is usually accurate since mod events +/// will trigger key press events but on some platforms we don't get them. +/// For example, on macOS, unfocused surfaces don't receive key events but +/// do receive mouse events so we have to rely on updated mods. +pub fn cursorPosCallback( + self: *Surface, + pos: apprt.CursorPos, + mods: ?input.Mods, +) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + // log.debug("cursor pos x={} y={} mods={?}", .{ pos.x, pos.y, mods }); + + // If the position is negative, it is outside our viewport and + // we need to clear any hover states. + if (pos.x < 0 or pos.y < 0) { + // Reset our hyperlink state + self.mouse.link_point = null; + if (self.mouse.over_link) { + self.mouse.over_link = false; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + } + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // No mouse point so we don't highlight links + self.renderer_state.mouse.point = null; + + // Mark the link's row as dirty, but continue with updating the + // mouse state below so we can scroll when our position is negative. + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; + } + + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + + // Update our modifiers if they changed + if (mods) |v| self.modsChanged(v); + + // The mouse position in the viewport + const pos_vp = self.posToViewport(pos.x, pos.y); + + // We always reset the over link status because it will be reprocessed + // below. But we need the old value to know if we need to undo mouse + // shape changes. + const over_link = self.mouse.over_link; + self.mouse.over_link = false; + + // We are reading/writing state for the remainder + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Stop selection scrolling when inside the viewport within a 1px buffer + // for fullscreen windows, but only when selection scrolling is active. + if (pos.y >= 1 and self.selection_scroll_active) { + self.queueIo( + .{ .selection_scroll = false }, + .locked, + ); + } + + // Update our mouse state. We set this to null initially because we only + // want to set it when we're not selecting or doing any other mouse + // event. + self.renderer_state.mouse.point = null; + + // If we have an inspector, we need to always record position information + if (self.inspector) |insp| { + insp.mouse.last_xpos = pos.x; + insp.mouse.last_ypos = pos.y; + + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + } }); + try self.queueRender(); + } + + // Handle link hovering + // We refresh links when + // 1. we were previously over a link + // OR + // 2. the cursor position has changed (either we have no previous state, or the state has + // changed) + // AND + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if ((over_link or + self.mouse.link_point == null or + (self.mouse.link_point != null and !self.mouse.link_point.?.eql(pos_vp))) and + (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false)))) + { + // If we were previously over a link, we always update. We do this so that if the text + // changed underneath us, even if the mouse didn't move, we update the URL hints and state + try self.mouseRefreshLinks(pos, pos_vp, over_link); + } + + // Do a mouse report + if (self.isMouseReporting()) report: { + // Shift overrides mouse "grabbing" in the window, taken from Kitty. + // This only applies if there is a mouse button pressed so that + // movement reports are not affected. + if (self.mouse.mods.shift and !self.mouseShiftCapture(false)) { + for (self.mouse.click_state) |state| { + if (state != .release) break :report; + } + } + + // We use the first mouse button we find pressed in order to report + // since the spec (afaict) does not say... + const button: ?input.MouseButton = button: for (self.mouse.click_state, 0..) |state, i| { + if (state == .press) + break :button @enumFromInt(i); + } else null; + + self.mouseReport(button, .motion, self.mouse.mods, pos); + + // If we're doing mouse motion tracking, we do not support text + // selection. + return; + } + + // Handle cursor position for text selection + if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] == .press) select: { + // Left click pressed but count zero can happen if mouse reporting is on. + // In this scenario, we mark the click state because we need that to + // properly make some mouse reports, but we don't keep track of the + // count because we don't want to handle selection. + if (self.mouse.left_click_count == 0) break :select; + + // If our terminal screen changed then we don't process this. We don't + // invalidate our pin or mouse state because if the screen switches + // back then we can continue our selection. + const t: *terminal.Terminal = self.renderer_state.terminal; + if (self.mouse.left_click_screen != t.screens.active_key) break :select; + + // All roads lead to requiring a re-render at this point. + try self.queueRender(); + + // If our y is negative, we're above the window. In this case, we scroll + // up. The amount we scroll up is dependent on how negative we are. + // We allow for a 1 pixel buffer at the top and bottom to detect + // scroll even in full screen windows. + // Note: one day, we can change this from distance to time based if we want. + //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); + const max_y: f32 = @floatFromInt(self.size.screen.height); + + // If the mouse is outside the viewport and we have the left + // mouse button pressed then we need to start the scroll timer. + if ((pos.y <= 1 or pos.y > max_y - 1) and + !self.selection_scroll_active) + { + self.queueIo( + .{ .selection_scroll = true }, + .locked, + ); + } + + // Convert to points + const screen: *terminal.Screen = t.screens.active; + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + + // Handle dragging depending on click count + switch (self.mouse.left_click_count) { + 1 => try self.dragLeftClickSingle(pin, pos.x), + 2 => try self.dragLeftClickDouble(pin), + 3 => try self.dragLeftClickTriple(pin), + 0 => unreachable, // handled above + else => unreachable, + } + + return; + } +} + +/// Double-click dragging moves the selection one "word" at a time. +fn dragLeftClickDouble( + self: *Surface, + drag_pin: terminal.Pin, +) !void { + const screen: *terminal.Screen = self.io.terminal.screens.active; + const click_pin = self.mouse.left_click_pin.?.*; + + // Get the word closest to our starting click. + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + self.config.selection_word_chars, + ) orelse { + try self.setSelection(null); + return; + }; + + // Get the word closest to our current point. + const word_current = screen.selectWordBetween( + drag_pin, + click_pin, + self.config.selection_word_chars, + ) orelse { + try self.setSelection(null); + return; + }; + + // If our current mouse position is before the starting position, + // then the selection start is the word nearest our current position. + if (drag_pin.before(click_pin)) { + try self.io.terminal.screens.active.select(.init( + word_current.start(), + word_start.end(), + false, + )); + } else { + try self.io.terminal.screens.active.select(.init( + word_start.start(), + word_current.end(), + false, + )); + } +} + +/// Triple-click dragging moves the selection one "line" at a time. +fn dragLeftClickTriple( + self: *Surface, + drag_pin: terminal.Pin, +) !void { + const screen: *terminal.Screen = self.io.terminal.screens.active; + const click_pin = self.mouse.left_click_pin.?.*; + + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; + + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); + + var sel = sel_ orelse return; + if (drag_pin.before(click_pin)) { + sel.startPtr().* = line.start(); + } else { + sel.endPtr().* = line.end(); + } + try self.io.terminal.screens.active.select(sel); +} + +fn dragLeftClickSingle( + self: *Surface, + drag_pin: terminal.Pin, + drag_x: f64, +) !void { + // This logic is in a separate function so that it can be unit tested. + try self.io.terminal.screens.active.select(mouseSelection( + self.mouse.left_click_pin.?.*, + drag_pin, + @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), + @intFromFloat(@max(0.0, drag_x)), + self.mouse.mods, + self.size, + )); +} + +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as mouse mods and screen size. +fn mouseSelection( + click_pin: terminal.Pin, + drag_pin: terminal.Pin, + click_x: u32, + drag_x: u32, + mods: input.Mods, + size: rendererpkg.Size, +) ?terminal.Selection { + // Explanation: + // + // # Normal selections + // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. + + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(size.cell.width)) * 0.6, + )); + + // We use this to clamp the pixel positions below. + const max_x = size.grid().columns * size.cell.width - 1; + + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; + + // We figure out the fractional part of the click x position similarly. + const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; + + // Whether or not this is a rectangular selection. + const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); + + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); + + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } + + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, + }; + } + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) + else + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + return null; + } + + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. + + return .init( + start_pin, + end_pin, + rectangle_selection, + ); +} + +/// Call to notify Ghostty that the color scheme for the terminal has +/// changed. +pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + const new_scheme: configpkg.ConditionalState.Theme = switch (scheme) { + .light => .light, + .dark => .dark, + }; + + // If our scheme didn't change, then we don't do anything. + if (self.config_conditional_state.theme == new_scheme) return; + + // Setup our conditional state which has the current color theme. + self.config_conditional_state.theme = new_scheme; + self.notifyConfigConditionalState(); + + // If mode 2031 is on, then we report the change live. + self.queueIo(.{ .color_scheme_report = .{ .force = false } }, .unlocked); +} + +pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate { + // Get our grid cell + const coord: rendererpkg.Coordinate = .{ .surface = .{ .x = xpos, .y = ypos } }; + const grid = coord.convert(.grid, self.size).grid; + return .{ .x = grid.x, .y = grid.y }; +} + +/// Scroll to the bottom of the viewport. +/// +/// Precondition: the render_state mutex must be held. +fn scrollToBottom(self: *Surface) !void { + self.io.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); +} + +fn hideMouse(self: *Surface) void { + if (self.mouse.hidden) return; + self.mouse.hidden = true; + _ = self.rt_app.performAction( + .{ .surface = self }, + .mouse_visibility, + .hidden, + ) catch |err| { + log.warn("apprt failed to set mouse visibility err={}", .{err}); + }; +} + +fn showMouse(self: *Surface) void { + if (!self.mouse.hidden) return; + self.mouse.hidden = false; + _ = self.rt_app.performAction( + .{ .surface = self }, + .mouse_visibility, + .visible, + ) catch |err| { + log.warn("apprt failed to set mouse visibility err={}", .{err}); + }; +} + +/// Perform a binding action. A binding is a keybinding. This function +/// must be called from the GUI thread. +/// +/// This function returns true if the binding action was performed. This +/// may return false if the binding action is not supported or if the +/// binding action would do nothing (i.e. previous tab with no tabs). +/// +/// NOTE: At the time of writing this comment, only previous/next tab +/// will ever return false. We can expand this in the future if it becomes +/// useful. We did previous/next tab so we could implement #498. +pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { + // Forward app-scoped actions to the app. Some app-scoped actions are + // special-cased here because they do some special things when performed + // from the surface. + if (action.scoped(.app)) |app_action| { + switch (app_action) { + .new_window => try self.app.newWindow( + self.rt_app, + .{ .parent = self }, + ), + + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + + else => try self.app.performAction( + self.rt_app, + action.scoped(.app).?, + ), + } + return true; + } + + switch (action.scoped(.surface).?) { + .csi, .esc => |data| { + // We need to send the CSI/ESC sequence as a single write request. + // If you split it across two then the shell can interpret it + // as two literals. + var buf: [128]u8 = undefined; + const full_data = switch (action) { + .csi => try std.fmt.bufPrint(&buf, "\x1b[{s}", .{data}), + .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), + else => unreachable, + }; + self.queueIo(try termio.Message.writeReq( + self.alloc, + full_data, + ), .unlocked); + + // CSI/ESC triggers a scroll. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.scrollToBottom() catch |err| { + log.warn("error scrolling to bottom err={}", .{err}); + }; + } + }, + + .text => |data| { + // For text we always allocate just because its easier to + // handle all cases that way. + const buf = try self.alloc.alloc(u8, data.len); + defer self.alloc.free(buf); + const text = configpkg.string.parse(buf, data) catch |err| { + log.warn( + "error parsing text binding text={s} err={}", + .{ data, err }, + ); + return true; + }; + self.queueIo(try termio.Message.writeReq( + self.alloc, + text, + ), .unlocked); + + // Text triggers a scroll. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.scrollToBottom() catch |err| { + log.warn("error scrolling to bottom err={}", .{err}); + }; + } + }, + + .cursor_key => |ck| { + // We send a different sequence depending on if we're + // in cursor keys mode. We're in "normal" mode if cursor + // keys mode is NOT set. + const normal = normal: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // With the lock held, we must scroll to the bottom. + // We always scroll to the bottom for these inputs. + self.scrollToBottom() catch |err| { + log.warn("error scrolling to bottom err={}", .{err}); + }; + + break :normal !self.io.terminal.modes.get(.cursor_keys); + }; + + if (normal) { + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); + } else { + self.queueIo(.{ .write_stable = ck.application }, .unlocked); + } + }, + + .reset => { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.renderer_state.terminal.fullReset(); + }, + + .start_search => { + // To save resources, we don't actually start a search here, + // we just notify the apprt. The real thread will start when + // the first needles are set. + return try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + }, + + .search_selection => { + const selection = try self.selectionString(self.alloc) orelse return false; + defer self.alloc.free(selection); + return try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = selection }, + ); + }, + + .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + + return performed; + }, + + .search => |text| search: { + const s: *Search = if (self.search) |*s| s else init: { + // If we're stopping the search and we had no prior search, + // then there is nothing to do. + if (text.len == 0) return false; + + // We need to assign directly to self.search because we need + // a stable pointer back to the thread state. + self.search = .{ + .state = try .init(self.alloc, .{ + .mutex = self.renderer_state.mutex, + .terminal = self.renderer_state.terminal, + .event_cb = &searchCallback, + .event_userdata = self, + }), + .thread = undefined, + }; + const s: *Search = &self.search.?; + errdefer s.state.deinit(); + + s.thread = try .spawn( + .{}, + terminal.search.Thread.threadMain, + .{&s.state}, + ); + s.thread.setName("search") catch {}; + + break :init s; + }; + + // Zero-length text means stop searching. + if (text.len == 0) { + s.deinit(); + self.search = null; + break :search; + } + + _ = s.state.mailbox.push( + .{ .change_needle = try .init( + self.alloc, + text, + ) }, + .forever, + ); + s.state.wakeup.notify() catch {}; + }, + + .navigate_search => |nav| { + const s: *Search = if (self.search) |*s| s else return false; + _ = s.state.mailbox.push( + .{ .select = switch (nav) { + .next => .next, + .previous => .prev, + } }, + .forever, + ); + s.state.wakeup.notify() catch {}; + }, + + .copy_to_clipboard => |format| { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + if (self.io.terminal.screens.active.selection) |sel| { + try self.copySelectionToClipboards( + sel, + &.{.standard}, + format, + ); + + // Clear the selection if configured to do so. + if (self.config.selection_clear_on_copy) { + if (self.setSelection(null)) { + self.queueRender() catch |err| { + log.warn("failed to queue render after clear selection err={}", .{err}); + }; + } else |err| { + log.warn("failed to clear selection after copy err={}", .{err}); + } + } + + return true; + } + + return false; + }, + + .copy_url_to_clipboard => { + // If the mouse isn't over a link, nothing we can do. + if (!self.mouse.over_link) return false; + const pos = try self.rt_surface.getCursorPos(); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (try self.linkAtPos(pos)) |link_info| { + const url_text = switch (link_info.action) { + .open => url_text: { + // For regex links, get the text from selection + break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{ + .sel = link_info.selection, + .trim = self.config.clipboard_trim_trailing_spaces, + })) catch |err| { + log.err("error reading url string err={}", .{err}); + return false; + }; + }, + + ._open_osc8 => url_text: { + // For OSC8 links, get the URI directly from hyperlink data + const uri = self.osc8URI(link_info.selection.start()) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); + return false; + }; + break :url_text try self.alloc.dupeZ(u8, uri); + }, + }; + defer self.alloc.free(url_text); + + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = url_text, + }}, false) catch |err| { + log.err("error copying url to clipboard err={}", .{err}); + return false; + }; + + return true; + } + + return false; + }, + + .copy_title_to_clipboard => return try self.rt_app.performAction( + .{ .surface = self }, + .copy_title_to_clipboard, + {}, + ), + + .paste_from_clipboard => return try self.startClipboardRequest( + .standard, + .{ .paste = {} }, + ), + + .paste_from_selection => return try self.startClipboardRequest( + .selection, + .{ .paste = {} }, + ), + + .increase_font_size => |delta| { + // Max delta is somewhat arbitrary. + const clamped_delta = @max(0, @min(255, delta)); + + log.debug("increase font size={}", .{clamped_delta}); + + // Max point size is somewhat arbitrary. + var size = self.font_size; + size.points = @min(size.points + clamped_delta, 255); + try self.setFontSize(size); + + // Mark that we manually adjusted the font size + self.font_size_adjusted = true; + }, + + .decrease_font_size => |delta| { + // Max delta is somewhat arbitrary. + const clamped_delta = @max(0, @min(255, delta)); + + log.debug("decrease font size={}", .{clamped_delta}); + + var size = self.font_size; + size.points = @max(1, size.points - clamped_delta); + try self.setFontSize(size); + + // Mark that we manually adjusted the font size + self.font_size_adjusted = true; + }, + + .reset_font_size => { + log.debug("reset font size", .{}); + + var size = self.font_size; + size.points = self.config.original_font_size; + try self.setFontSize(size); + + // Reset font size also resets the manual adjustment state + self.font_size_adjusted = false; + }, + + .set_font_size => |points| { + log.debug("set font size={d}", .{points}); + + var size = self.font_size; + size.points = std.math.clamp(points, 1.0, 255.0); + try self.setFontSize(size); + + // Mark that we manually adjusted the font size + self.font_size_adjusted = true; + }, + + .prompt_surface_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .surface, + ), + + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .tab, + ), + + .set_surface_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + }, + + .set_tab_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_tab_title, + .{ .title = title }, + ); + }, + + .clear_screen => { + // This is a duplicate of some of the logic in termio.clearScreen + // but we need to do this here so we can know the answer before + // we send the message. If the currently active screen is on the + // alternate screen then clear screen does nothing so we want to + // return false so the keybind can be unconsumed. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.screens.active_key == .alternate) return false; + } + + self.queueIo(.{ + .clear_screen = .{ .history = true }, + }, .unlocked); + }, + + .scroll_to_top => { + self.queueIo(.{ + .scroll_viewport = .{ .top = {} }, + }, .unlocked); + }, + + .scroll_to_bottom => { + self.queueIo(.{ + .scroll_viewport = .{ .bottom = {} }, + }, .unlocked); + }, + + .scroll_to_row => |n| { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.screens.active.scroll(.{ .row = n }); + } + + try self.queueRender(); + }, + + .scroll_to_selection => { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selection orelse return false; + const tl = sel.topLeft(self.io.terminal.screens.active); + self.io.terminal.screens.active.scroll(.{ .pin = tl }); + } + + try self.queueRender(); + }, + + .scroll_page_up => { + const rows: isize = @intCast(self.size.grid().rows); + self.queueIo(.{ + .scroll_viewport = .{ .delta = -1 * rows }, + }, .unlocked); + }, + + .scroll_page_down => { + const rows: isize = @intCast(self.size.grid().rows); + self.queueIo(.{ + .scroll_viewport = .{ .delta = rows }, + }, .unlocked); + }, + + .scroll_page_fractional => |fraction| { + const rows: f32 = @floatFromInt(self.size.grid().rows); + const delta: isize = @intFromFloat(@trunc(fraction * rows)); + self.queueIo(.{ + .scroll_viewport = .{ .delta = delta }, + }, .unlocked); + }, + + .scroll_page_lines => |lines| { + self.queueIo(.{ + .scroll_viewport = .{ .delta = lines }, + }, .unlocked); + }, + + .jump_to_prompt => |delta| { + self.queueIo(.{ + .jump_to_prompt = @intCast(delta), + }, .unlocked); + }, + + .write_screen_file => |v| try self.writeScreenFile( + .screen, + v, + ), + + .write_scrollback_file => |v| try self.writeScreenFile( + .history, + v, + ), + + .write_selection_file => |v| try self.writeScreenFile( + .selection, + v, + ), + + .new_tab => return try self.rt_app.performAction( + .{ .surface = self }, + .new_tab, + {}, + ), + + .close_tab => |v| return try self.rt_app.performAction( + .{ .surface = self }, + .close_tab, + switch (v) { + .this => .this, + .other => .other, + .right => .right, + }, + ), + + inline .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + => |v, tag| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_tab, + switch (tag) { + .previous_tab => .previous, + .next_tab => .next, + .last_tab => .last, + .goto_tab => @enumFromInt(v), + else => comptime unreachable, + }, + ), + + .move_tab => |position| return try self.rt_app.performAction( + .{ .surface = self }, + .move_tab, + .{ .amount = position }, + ), + + .new_split => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .new_split, + switch (direction) { + .right => .right, + .left => .left, + .down => .down, + .up => .up, + .auto => if (self.size.screen.width > self.size.screen.height) + .right + else + .down, + }, + ), + + .goto_split => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_split, + switch (direction) { + inline else => |tag| @field( + apprt.action.GotoSplit, + @tagName(tag), + ), + }, + ), + + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + .previous => .previous, + .next => .next, + }, + ), + + .resize_split => |value| return try self.rt_app.performAction( + .{ .surface = self }, + .resize_split, + .{ + .amount = value[1], + .direction = switch (value[0]) { + inline else => |tag| @field( + apprt.action.ResizeSplit.Direction, + @tagName(tag), + ), + }, + }, + ), + + .equalize_splits => return try self.rt_app.performAction( + .{ .surface = self }, + .equalize_splits, + {}, + ), + + .toggle_split_zoom => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_split_zoom, + {}, + ), + + .toggle_readonly => { + self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); + return true; + }, + + .reset_window_size => return try self.rt_app.performAction( + .{ .surface = self }, + .reset_window_size, + {}, + ), + + .toggle_maximize => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_maximize, + {}, + ), + + .toggle_fullscreen => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_fullscreen, + switch (self.config.macos_non_native_fullscreen) { + .false => .native, + .true => .macos_non_native, + .@"visible-menu" => .macos_non_native_visible_menu, + .@"padded-notch" => .macos_non_native_padded_notch, + }, + ), + + .toggle_window_decorations => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_window_decorations, + {}, + ), + + .toggle_tab_overview => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_tab_overview, + {}, + ), + + .toggle_window_float_on_top => return try self.rt_app.performAction( + .{ .surface = self }, + .float_window, + .toggle, + ), + + .toggle_secure_input => return try self.rt_app.performAction( + .{ .surface = self }, + .secure_input, + .toggle, + ), + + .toggle_mouse_reporting => { + self.config.mouse_reporting = !self.config.mouse_reporting; + log.debug("mouse reporting toggled: {}", .{self.config.mouse_reporting}); + }, + + .toggle_command_palette => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_command_palette, + {}, + ), + + .toggle_background_opacity => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_background_opacity, + {}, + ), + + .show_on_screen_keyboard => return try self.rt_app.performAction( + .{ .surface = self }, + .show_on_screen_keyboard, + {}, + ), + + .select_all => { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + const sel = self.io.terminal.screens.active.selectAll(); + if (sel) |s| { + try self.setSelection(s); + try self.queueRender(); + } + }, + + .inspector => |mode| return try self.rt_app.performAction( + .{ .surface = self }, + .inspector, + switch (mode) { + inline else => |tag| @field( + apprt.action.Inspector, + @tagName(tag), + ), + }, + ), + + .close_surface => self.close(), + + .close_window => return try self.rt_app.performAction( + .{ .surface = self }, + .close_window, + {}, + ), + + inline .activate_key_table, + .activate_key_table_once, + => |name, tag| { + // Look up the table in our config + const set = self.config.keybind.tables.getPtr(name) orelse { + log.debug("key table not found: {s}", .{name}); + return false; + }; + + // If this is the same table as is currently active, then + // do nothing. + if (self.keyboard.table_stack.items.len > 0) { + const items = self.keyboard.table_stack.items; + const active = items[items.len - 1].set; + if (active == set) { + log.debug("ignoring duplicate activate table: {s}", .{name}); + return false; + } + } + + // If we're already at the max, ignore it. + if (self.keyboard.table_stack.items.len >= max_active_key_tables) { + log.info( + "ignoring activate table, max depth reached: {s}", + .{name}, + ); + return false; + } + + // Add the table to the stack. + try self.keyboard.table_stack.append(self.alloc, .{ + .set = set, + .once = tag == .activate_key_table_once, + }); + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .{ .activate = name }, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + + log.debug("key table activated: {s}", .{name}); + }, + + .deactivate_key_table => { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Final key table active, clear our state. + 1 => self.keyboard.table_stack.clearAndFree(self.alloc), + + // Restore the prior key table. We don't free any memory in + // this case because we assume it will be freed later when + // we finish our key table. + else => _ = self.keyboard.table_stack.pop(), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + }, + + .deactivate_all_key_tables => { + return try self.deactivateAllKeyTables(); + }, + + .end_key_sequence => { + // End the key sequence and flush queued keys to the terminal, + // but don't encode the key that triggered this action. This + // will do that because leaf keys (keys with bindings) aren't + // in the queued encoding list. + self.endKeySequence(.flush, .retain); + }, + + .crash => |location| switch (location) { + .main => @panic("crash binding action, crashing intentionally"), + + .render => { + _ = self.renderer_thread.mailbox.push(.{ .crash = {} }, .{ .forever = {} }); + self.queueRender() catch |err| { + // Not a big deal if this fails. + log.warn("failed to notify renderer of crash message err={}", .{err}); + }; + }, + + .io => self.queueIo(.{ .crash = {} }, .unlocked), + }, + + .adjust_selection => |direction| { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + const screen: *terminal.Screen = self.io.terminal.screens.active; + const sel = if (screen.selection) |*sel| sel else { + // If we don't have a selection we do not perform this + // action, allowing the keybind to fall through to the + // terminal. + return false; + }; + sel.adjust(screen, switch (direction) { + .left => .left, + .right => .right, + .up => .up, + .down => .down, + .page_up => .page_up, + .page_down => .page_down, + .home => .home, + .end => .end, + .beginning_of_line => .beginning_of_line, + .end_of_line => .end_of_line, + }); + + // If the selection endpoint is outside of the current viewpoint, + // scroll it in to view. Note we always specifically use sel.end + // because that is what adjust modifies. + scroll: { + const viewport_tl = screen.pages.getTopLeft(.viewport); + const viewport_br = screen.pages.getBottomRight(.viewport).?; + if (sel.end().isBetween(viewport_tl, viewport_br)) + break :scroll; + + // Our end point is not within the viewport. If the end + // point is after the br then we need to adjust the end so + // that it is at the bottom right of the viewport. + const target = if (sel.end().before(viewport_tl)) + sel.end() + else + sel.end().up(screen.pages.rows - 1) orelse sel.end(); + + screen.scroll(.{ .pin = target }); + } + + // Queue a render so its shown + screen.dirty.selection = true; + try self.queueRender(); + }, + } + + return true; +} + +/// Returns true if performing the given action result in closing +/// the surface. This is used to determine if our self pointer is +/// still valid after performing some binding action. +fn closingAction(action: input.Binding.Action) bool { + return switch (action) { + .close_surface, + .close_window, + .close_tab, + => true, + + else => false, + }; +} + +/// The portion of the screen to write for writeScreenFile. +const WriteScreenLoc = enum { + screen, // Full screen + history, // History (scrollback) + selection, // Selected text +}; + +fn writeScreenFile( + self: *Surface, + loc: WriteScreenLoc, + write_screen: input.Binding.Action.WriteScreen, +) !void { + // Create a temporary directory to store our scrollback. + var tmp_dir = try internal_os.TempDir.init(); + errdefer tmp_dir.deinit(); + + var filename_buf: [std.fs.max_path_bytes]u8 = undefined; + const filename = try std.fmt.bufPrint( + &filename_buf, + "{s}.{s}", + .{ + @tagName(loc), + switch (write_screen.emit) { + .plain, .vt => "txt", + .html => "html", + }, + }, + ); + + // Open our scrollback file + var file = try tmp_dir.dir.createFile( + filename, + switch (builtin.os.tag) { + .windows => .{}, + else => .{ .mode = 0o600 }, + }, + ); + defer file.close(); + + // Screen.dumpString writes byte-by-byte, so buffer it + var buf: [4096]u8 = undefined; + var file_writer = file.writer(&buf); + var buf_writer = &file_writer.interface; + + // Write the scrollback contents. This requires a lock. + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // We only dump history if we have history. We still keep + // the file and write the empty file to the pty so that this + // command always works on the primary screen. + const pages = &self.io.terminal.screens.active.pages; + const sel_: ?terminal.Selection = switch (loc) { + .history => history: { + // We do not support this for alternate screens + // because they don't have scrollback anyways. + if (self.io.terminal.screens.active_key == .alternate) { + break :history null; + } + + break :history terminal.Selection.init( + pages.getTopLeft(.history), + pages.getBottomRight(.history) orelse + break :history null, + false, + ); + }, + + .screen => screen: { + break :screen terminal.Selection.init( + pages.getTopLeft(.screen), + pages.getBottomRight(.screen) orelse + break :screen null, + false, + ); + }, + + .selection => self.io.terminal.screens.active.selection, + }; + + const sel = sel_ orelse { + // If we have no selection we have no data so we do nothing. + tmp_dir.deinit(); + return; + }; + + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, .{ + .emit = switch (write_screen.emit) { + .plain => .plain, + .vt => .vt, + .html => .html, + }, + .unwrap = true, + .trim = false, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }); + formatter.content = .{ .selection = sel.ordered( + self.io.terminal.screens.active, + .forward, + ) }; + try formatter.format(buf_writer); + } + try buf_writer.flush(); + + // Get the final path + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath(filename, &path_buf); + + switch (write_screen.action) { + .copy => { + const pathZ = try self.alloc.dupeZ(u8, path); + defer self.alloc.free(pathZ); + try self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = pathZ, + }}, false); + }, + .open => try self.openUrl(.{ + .kind = switch (write_screen.emit) { + .plain, .vt => .text, + .html => .html, + }, + .url = path, + }), + .paste => self.queueIo(try termio.Message.writeReq( + self.alloc, + path, + ), .unlocked), + } +} + +/// Call this to complete a clipboard request sent to apprt. This should +/// only be called once for each request. The data is immediately copied so +/// it is safe to free the data after this call. +/// +/// If `confirmed` is true then any clipboard confirmation prompts are skipped: +/// +/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe +/// data is defined as data that contains newlines, though this definition +/// may change later to detect other scenarios. +/// +/// - For OSC 52 reads and writes no prompt is shown to the user if +/// `confirmed` is true. +/// +/// If `confirmed` is false then this may return either an UnsafePaste or +/// UnauthorizedPaste error, depending on the type of clipboard request. +pub fn completeClipboardRequest( + self: *Surface, + req: apprt.ClipboardRequest, + data: [:0]const u8, + confirmed: bool, +) !void { + switch (req) { + .paste => try self.completeClipboardPaste(data, confirmed), + + .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( + data, + clipboard, + confirmed, + ), + + .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !confirmed), + } +} + +/// This starts a clipboard request, with some basic validation. For example, +/// an OSC 52 request is not actually requested if OSC 52 is disabled. +/// +/// Returns true if the request was started, false if it was not (e.g., clipboard +/// doesn't contain text for paste requests). This allows performable keybinds +/// to pass through when the action cannot be performed. +fn startClipboardRequest( + self: *Surface, + loc: apprt.Clipboard, + req: apprt.ClipboardRequest, +) !bool { + switch (req) { + .paste => {}, // always allowed + .osc_52_read => if (self.config.clipboard_read == .deny) { + log.info( + "application attempted to read clipboard, but 'clipboard-read' is set to deny", + .{}, + ); + return false; + }, + + // No clipboard write code paths travel through this function + .osc_52_write => unreachable, + } + + return try self.rt_surface.clipboardRequest(loc, req); +} + +fn completeClipboardPaste( + self: *Surface, + data: []const u8, + allow_unsafe: bool, +) !void { + if (data.len == 0) return; + + const encode_opts: input.paste.Options = encode_opts: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const opts: input.paste.Options = .fromTerminal(&self.io.terminal); + + // If we have paste protection enabled, we detect unsafe pastes and return + // an error. The error approach allows apprt to attempt to complete the paste + // before falling back to requesting confirmation. + // + // We do not do this for bracketed pastes because bracketed pastes are + // by definition safe since they're framed. + const unsafe = unsafe: { + // If we've disabled paste protection then we always allow the paste. + if (!self.config.clipboard_paste_protection) break :unsafe false; + + // If we're allowed to paste unsafe data then we always allow the paste. + // This is set during confirmation usually. + if (allow_unsafe) break :unsafe false; + + if (opts.bracketed) { + // If we're bracketed and the paste contains and ending + // bracket then something naughty might be going on and we + // never trust it. + if (std.mem.indexOf(u8, data, "\x1B[201~") != null) break :unsafe true; + + // If we are bracketed and configured to trust that then the + // paste is not unsafe. + if (self.config.clipboard_paste_bracketed_safe) break :unsafe false; + } + + break :unsafe !input.paste.isSafe(data); + }; + + if (unsafe) { + log.info("potentially unsafe paste detected, rejecting until confirmation", .{}); + return error.UnsafePaste; + } + + // With the lock held, we must scroll to the bottom. + // We always scroll to the bottom for these inputs. + self.scrollToBottom() catch |err| { + log.warn("error scrolling to bottom err={}", .{err}); + }; + + break :encode_opts opts; + }; + + // Encode the data. In most cases this doesn't require any + // copies, so we optimize for that case. + var data_duped: ?[]u8 = null; + const vecs = input.paste.encode(data, encode_opts) catch |err| switch (err) { + error.MutableRequired => vecs: { + const buf: []u8 = try self.alloc.dupe(u8, data); + errdefer self.alloc.free(buf); + data_duped = buf; + break :vecs input.paste.encode(buf, encode_opts); + }, + }; + defer if (data_duped) |v| { + // This code path means the data did require a copy and mutation. + // We must free it. + self.alloc.free(v); + }; + + for (vecs) |vec| if (vec.len > 0) { + self.queueIo(try termio.Message.writeReq( + self.alloc, + vec, + ), .unlocked); + }; +} + +fn completeClipboardReadOSC52( + self: *Surface, + data: []const u8, + clipboard_type: apprt.Clipboard, + confirmed: bool, +) !void { + // We should never get here if clipboard-read is set to deny + assert(self.config.clipboard_read != .deny); + + // If clipboard-read is set to ask and we haven't confirmed with the user, + // do that now + if (self.config.clipboard_read == .ask and !confirmed) { + return error.UnauthorizedPaste; + } + + // Even if the clipboard data is empty we reply, since presumably + // the client app is expecting a reply. We first allocate our buffer. + // This must hold the base64 encoded data PLUS the OSC code surrounding it. + const enc = std.base64.standard.Encoder; + const size = enc.calcSize(data.len); + var buf = try self.alloc.alloc(u8, size + 9); // const for OSC + defer self.alloc.free(buf); + + const kind: u8 = switch (clipboard_type) { + .standard => 'c', + .selection => 's', + .primary => 'p', + }; + + // Wrap our data with the OSC code + const prefix = try std.fmt.bufPrint(buf, "\x1b]52;{c};", .{kind}); + assert(prefix.len == 7); + buf[buf.len - 2] = '\x1b'; + buf[buf.len - 1] = '\\'; + + // Do the base64 encoding + const encoded = enc.encode(buf[prefix.len..], data); + assert(encoded.len == size); + + self.queueIo(try termio.Message.writeReq( + self.alloc, + buf, + ), .unlocked); +} + +fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { + // Wyhash is used to hash the contents of the desktop notification to limit + // how fast identical notifications can be sent sequentially. + const hash_algorithm = std.hash.Wyhash; + + const now = try std.time.Instant.now(); + + // Set a limit of one desktop notification per second so that the OS + // doesn't kill us when we run out of resources. + if (self.app.last_notification_time) |last| { + if (now.since(last) < 1 * std.time.ns_per_s) { + log.warn("rate limiting desktop notifications", .{}); + return; + } + } + + const new_digest = d: { + var hash = hash_algorithm.init(0); + hash.update(title); + hash.update(body); + break :d hash.final(); + }; + + // Set a limit of one notification per five seconds for desktop + // notifications with identical content. + if (self.app.last_notification_time) |last| { + if (self.app.last_notification_digest == new_digest) { + if (now.since(last) < 5 * std.time.ns_per_s) { + log.warn("suppressing identical desktop notification", .{}); + return; + } + } + } + + self.app.last_notification_time = now; + self.app.last_notification_digest = new_digest; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .desktop_notification, + .{ + .title = title, + .body = body, + }, + ); +} + +fn crashThreadState(self: *Surface) crash.sentry.ThreadState { + return .{ + .type = .main, + .surface = self, + }; +} + +/// Tell the surface to present itself to the user. This may involve raising the +/// window and switching tabs. +fn presentSurface(self: *Surface) !void { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .present_terminal, + {}, + ); +} + +/// Utility function for the unit tests for mouse selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The size tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testMouseSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: terminal.size.CellCountInt, + start_y: u32, + end_x: terminal.size.CellCountInt, + end_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; + + try std.testing.expectEqualDeep(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + )); +} + +/// Like `testMouseSelection` but checks that the resulting selection is null. +/// +/// See `testMouseSelection` for more details. +fn testMouseSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + try std.testing.expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + ), + ); +} + +/// Get information about the process(es) running within the surface. Returns +/// `null` if there was an error getting the information or the information is +/// not available on a particular platform. +pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return self.io.getProcessInfo(info); +} + +test "Surface: selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single cell selection + try testMouseSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); + + // -- RTL + // single cell selection + try testMouseSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + + // -- Wrapping + // LTR, wrap excluded cells + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); + // RTL, wrap excluded cells + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); +} + +test "Surface: rectangle selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off + + // -- LTR + // single column selection + try testMouseSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); + + // -- RTL + // single column selection + try testMouseSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + + // -- Wrapping + // LTR, do not wrap + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); + // RTL, do not wrap + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); +} |
