//! 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 = @import("global.zig");
const oni = @import("oniguruma");
const simd = @import("simd/main.zig");
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,

/// Whether this surface may be visible. Unknown visibility is considered
/// visible so reporting remains conservative.
visible: 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.Io.Timestamp = null,

/// Search state
search: ?Search = null,

/// Used to rate limit BEL handling.
last_bell_time: ?std.Io.Timestamp = 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 = .{},

    /// Gesture state for text selection.
    selection_gesture: terminal.SelectionGesture = .init,

    /// 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,

    /// Return the left-click pin only if it still belongs to the active screen.
    fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin {
        return self.selection_gesture.validatedLeftClickPin(screens);
    }
};

/// 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,
    middle_click_action: configpkg.MiddleClickAction,
    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_osc8: bool,
    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",
            .middle_click_action = config.@"middle-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_osc8 = config.@"link-osc8",
            .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.Io.Mutex);
    mutex.* = .init;
    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 = candidate: {
                    const rng_impl: std.Random.IoSource = .{ .io = global.io() };
                    const rng = rng_impl.interface();
                    break :candidate rng.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 global.environMap() catch std.process.Environ.Map.init(alloc);
        };
        errdefer env.deinit();

        // don't leak GHOSTTY_LOG to any subprocesses
        _ = env.orderedRemove("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.resourcesDir().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(global.io(), "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(global.io(), "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.mouse.selection_gesture.deinit(&self.io.terminal);
    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 id={x}", .{self.id});
}

/// 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, taking ownership of `msg`.
///
/// 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,
            => {
                msg.deinit();
                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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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(global.io(), .{ .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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        assert(self.renderer_state.inspector != null);
        self.renderer_state.inspector = null;
    }

    // Notify our components we have deactivated inspector
    _ = self.renderer_thread.mailbox.push(global.io(), .{ .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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());
            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 });
        },

        .kitty_clipboard_read => |req| try self.kittyClipboardRead(req),

        .kitty_clipboard_write => |req| try self.kittyClipboardWrite(req),

        .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();

            var stack = std.heap.stackFallback(256, self.alloc);
            const alloc = stack.get();
            const str = try alloc.dupeZ(u8, w.slice());
            defer 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(&notification.title, 0);
            const body = std.mem.sliceTo(&notification.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.Io.Timestamp = .now(global.io(), .awake);
            if (self.last_bell_time) |last| {
                if (last.durationTo(now).toMilliseconds() < 100) 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 = .now(global.io(), .awake);
        },

        .stop_command => |v| timer: {
            const end: std.Io.Timestamp = .now(global.io(), .awake);
            const start = self.command_timer orelse break :timer;
            self.command_timer = null;
            const duration_raw = start.durationTo(end).nanoseconds;
            assert(duration_raw >= 0 and duration_raw <= std.math.maxInt(u64));

            const duration: Duration = .{
                .duration = @as(
                    u64,
                    @intCast(std.math.clamp(start.durationTo(end).nanoseconds, 0, std.math.maxInt(u64))),
                ),
            };
            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 our gesture doesn't want autoscrolling then disable it.
    const was_autoscrolling = self.mouse.selection_gesture.left_drag_autoscroll != .none;
    if (!was_autoscrolling) {
        self.queueIo(
            .{ .selection_scroll = false },
            .unlocked,
        );
        return;
    }

    const pos = try self.rt_surface.getCursorPos();
    const pos_vp = self.posToViewport(pos.x, pos.y);

    // We need our locked state for the remainder
    self.renderer_state.mutex.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    const t: *terminal.Terminal = self.renderer_state.terminal;

    const selection = self.mouse.selection_gesture.autoscrollTick(t, .{
        .viewport = pos_vp,
        .xpos = pos.x,
        .ypos = pos.y,
        .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
        .word_boundary_codepoints = self.config.selection_word_chars,
        .geometry = .{
            .columns = @intCast(self.size.grid().columns),
            .cell_width = self.size.cell.width,
            .padding_left = self.size.padding.left,
            .screen_height = self.size.screen.height,
        },
    });

    // If we're no longer autoscrolling for whatever reason, disable it.
    if (self.mouse.selection_gesture.left_drag_autoscroll == .none) {
        self.queueIo(
            .{ .selection_scroll = false },
            .locked,
        );
    }

    // If our left click was invalidated, ignore the result. This isn't
    // strictly necessary but its a nice to have.
    if (self.mouse.selection_gesture.left_click_count == 0) return;

    // We modified our viewport and selection so we need to queue
    // a render.
    try self.setSelection(selection);
    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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // 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(
                global.io(),
                .{ .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(
                    global.io(),
                    .{ .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(
                    global.io(),
                    .{ .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(
                global.io(),
                .{ .search_selected_match = null },
                .forever,
            );
            _ = self.renderer_thread.mailbox.push(
                global.io(),
                .{ .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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());
            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.activeLeftClickPin(&self.io.terminal.screens) 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(global.io(), 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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.Io.Dir.accessAbsolute(global.io(), 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.lockUncancelable(global.io());
    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(global.io());

    // 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;
    }

    // Decode with the SIMD decoder, strict per the Kitty clipboard
    // spec that also governs OSC 52 base64 handling: a request with
    // characters outside the base64 alphabet is discarded entirely
    // (never partially decoded), while a missing-padding tail is
    // tolerated since OSC 52 has no way to report errors.
    var buf = try self.alloc.allocSentinel(u8, simd.base64.maxLen(data), 0);
    defer self.alloc.free(buf);
    const decoded = simd.base64.decodeStrict(data, buf, .optional) catch {
        log.info("application sent invalid base64 data for OSC 52", .{});
        return;
    };
    buf[decoded.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[0..decoded.len :0],
    }}, 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_buf: [2]apprt.ClipboardContent = undefined;
    var contents: std.ArrayList(apprt.ClipboardContent) = .initBuffer(&contents_buf);
    switch (format) {
        .plain => {
            var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts);
            formatter.content = .{ .selection = sel };
            try formatter.format(&aw.writer);
            contents.appendAssumeCapacity(.{
                .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
            contents.appendAssumeCapacity(.{
                .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
            contents.appendAssumeCapacity(.{
                .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);
            contents.appendAssumeCapacity(.{
                .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
            contents.appendAssumeCapacity(.{
                .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 active selection and notify the apprt on a genuine state
/// transition. All selection mutations route through here rather than
/// `screen.select` directly so the notification fires consistently. To
/// also copy per `copy_on_select`, use `setSelectionAndCopy`.
///
/// This must be called with the renderer mutex held.
fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
    // Compute the transition before `select` below, which untracks (frees)
    // the previous selection's tracked pins; reading them after would be a
    // use-after-free.
    const prev_ = self.io.terminal.screens.active.selection;
    const changed = changed: {
        const prev = prev_ orelse break :changed sel_ != null;
        const sel = sel_ orelse break :changed true;
        break :changed !sel.eql(prev);
    };

    try self.io.terminal.screens.active.select(sel_);

    if (changed) {
        _ = self.rt_app.performAction(
            .{ .surface = self },
            .selection_changed,
            {},
        ) catch |err| {
            log.warn("apprt failed selection_changed notification err={}", .{err});
        };
    }
}

/// Set a selection and, per `copy_on_select`, copy it to the clipboard.
/// For committing selection gestures (mouse release, select-all binding).
///
/// This must be called with the renderer mutex held.
fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void {
    try self.setSelection(sel);

    // If copy on select is false then exit early.
    if (self.config.copy_on_select == .false) 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(global.io(), .{
        .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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());

    // 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.ArrayList(Codepoint) = .empty;
    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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());
            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) {
            write_req.deinit();
            self.close();
            return .closed;
        }

        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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        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| {
            // 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| {
                self.keyboard.sequence_queued.append(self.alloc, req) catch |err| {
                    req.deinit();
                    return err;
                };
            }

            // Setup the next set we'll look at only after all fallible work.
            self.keyboard.sequence_set = set;

            // 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();

        try input.key_encode.encode(
            &alloc_writer.writer,
            event,
            encoding_opts,
        );
        break :req .{ .alloc = .{
            .alloc = self.alloc,
            .data = try alloc_writer.toOwnedSlice(),
        } };
    };

    // 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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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;

    // Avoid duplicate renderer and visibility reports.
    if (self.visible == visible) return;
    self.visible = visible;

    // Update the terminal state for synchronous queries, then notify the IO
    // thread so it can emit a mode 2033 report when enabled.
    self.renderer_state.mutex.lockUncancelable(global.io());
    self.io.terminal.flags.visible = visible;
    const report_visibility = self.io.terminal.modes.get(.report_visibility);
    self.renderer_state.mutex.unlock(global.io());
    if (report_visibility) {
        self.queueIo(.{ .visibility_report = .{
            .visible = visible,
            .force = false,
        } }, .unlocked);
    }

    _ = self.renderer_thread.mailbox.push(global.io(), .{
        .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(global.io(), .{
        .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.lockUncancelable(global.io());
        self.io.terminal.flags.focused = focused;
        self.renderer_state.mutex.unlock(global.io());
        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: {
            if (comptime builtin.target.os.tag.isDarwin()) {
                // 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;
            } else {
                break :yoff_adjusted yoff * 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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // 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;
}

pub fn mouseReportingActive(self: *Surface) bool {
    self.renderer_state.mutex.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    return self.isMouseReporting();
}

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.lockUncancelable(global.io());
    defer if (lock) self.renderer_state.mutex.unlock(global.io());

    // 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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());
    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.selection_gesture.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.
            const click_time = self.mouse.selection_gesture.left_click_time orelse
                break :extend_selection;
            const since = click_time.untilNow(global.io(), .awake);
            if (since.toNanoseconds() <= self.config.mouse_interval) {
                // Click interval very short, we may be increasing
                // click counts so we don't extend the selection.
                break :extend_selection;
            }

            const pos = try self.rt_surface.getCursorPos();
            try self.cursorPosCallback(pos, null);
            return true;
        }
    }

    if (button == .left and action == .release) {
        self.renderer_state.mutex.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // The selection gesture tracks whether a press became a drag by
        // comparing the release cell to the original press cell. Resolve the
        // release position and pin before notifying the gesture so later
        // release handling can query that state.
        const release_pos: ?apprt.CursorPos = self.rt_surface.getCursorPos() catch |err| pos: {
            log.warn("error reading cursor position for mouse release err={}", .{err});
            break :pos null;
        };

        // If we can't map the release position to a cell, pass null so the
        // gesture can conservatively treat the release as having moved away
        // from the pressed cell.
        const release_pin: ?terminal.Pin = if (release_pos) |pos| pin: {
            const release_vp = self.posToViewport(pos.x, pos.y);
            break :pin self.io.terminal.screens.active.pages.pin(.{ .viewport = .{
                .x = release_vp.x,
                .y = release_vp.y,
            } });
        } else null;
        self.mouse.selection_gesture.release(
            self.renderer_state.terminal,
            .{ .pin = release_pin },
        );

        // 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 },
                .locked,
            );
        }

        // 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) {
            const prev_ = self.io.terminal.screens.active.selection;
            if (prev_) |prev| {
                try self.setSelectionAndCopy(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 and !self.mouse.selection_gesture.left_click_dragged) {
            // We are holding the renderer lock, but this should just be
            // a cached value.
            const pos = release_pos orelse 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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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.selection_gesture.reset(self.renderer_state.terminal);

            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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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 pin;
        };

        var press_selection = try self.mouse.selection_gesture.press(t, .{
            .time = std.Io.Timestamp.now(global.io(), .awake),
            .pin = pin,
            .xpos = pos.x,
            .ypos = pos.y,
            .max_distance = @floatFromInt(self.size.cell.width),
            .repeat_interval = self.config.mouse_interval,
            .word_boundary_codepoints = self.config.selection_word_chars,
            .behaviors = &.{
                .cell,
                .word,
                if (mods.ctrlOrSuper()) .output else .line,
            },
        });

        // The gesture owns the standard single/double/triple-click selection
        // behavior. Surface keeps terminal-surface-specific overrides here.
        switch (self.mouse.selection_gesture.left_click_count) {
            1 => {},

            // Double click on a URL selects the entire URL instead of the
            // standard word selection returned by the gesture.
            2 => {
                // Try link detection without requiring modifier keys.
                if (self.linkAtPin(
                    pin,
                    null,
                )) |result_| {
                    if (result_) |result| {
                        press_selection = result.selection;
                    }
                } else |_| {
                    // Ignore any errors, likely regex errors.
                }
            },

            3 => {},

            // We should be bounded by 1 to 3
            else => unreachable,
        }

        // Use `setSelection` (not `setSelectionAndCopy`) here to avoid
        // touching the selection clipboard: for left mouse clicks we only
        // copy on release.
        if (press_selection) |selection| {
            try self.setSelection(selection);
            try self.queueRender();
        } else if (self.mouse.selection_gesture.left_click_count == 1 and
            self.io.terminal.screens.active.selection != null)
        {
            try self.setSelection(null);
            try self.queueRender();
        }
    }

    // Middle-click paste source follows copy-on-select: when copy-on-select
    // targets the selection clipboard, middle-click reads from it; when
    // copy-on-select targets the system clipboard, middle-click reads from
    // that instead. Falls back to the standard clipboard on platforms that
    // do not support the selection clipboard.
    if (button == .middle and action == .press) switch (self.config.middle_click_action) {
        .ignore => {},
        .@"primary-paste" => {
            const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) {
                .clipboard => .standard,
                .true, .false => if (self.rt_surface.supportsClipboard(.selection))
                    .selection
                else
                    .standard,
            };
            _ = try self.startClipboardRequest(clipboard, .{ .paste = clipboard });
        },
    };

    // 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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // 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.setSelectionAndCopy(link.selection);
                } else {
                    const sel = screen.selectWord(
                        pin,
                        self.config.selection_word_chars,
                    ) orelse break :sel;
                    try self.setSelectionAndCopy(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(global.io());
                defer self.renderer_state.mutex.lockUncancelable(global.io());
                _ = try self.startClipboardRequest(.standard, .{ .paste = .standard });

                // 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(global.io());
                defer self.renderer_state.mutex.lockUncancelable(global.io());
                _ = try self.startClipboardRequest(.standard, .{ .paste = .standard });
            },
        }

        // Consume the event such that the context menu is not displayed.
        return true;
    }

    return false;
}

/// Requires the renderer state mutex is held.
fn maybePromptClick(self: *Surface) !bool {
    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 the left click moved away from its pressed cell then releasing the
    // mouse completes the drag gesture and we don't do prompt moving.
    if (self.mouse.selection_gesture.left_click_dragged) return false;

    // If we have a selection currently, then releasing the mouse completes
    // the selection and we don't do prompt moving.
    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 => |v| {
            // For the event, we always send a left-click press event.
            // This matches what Kitty sends.
            const key: u8, const y: u32 = switch (v) {
                .absolute => .{ 1, pos_vp.y +| 1 },
                .relative => .{ 2, pos_vp.y -| prompt_pin.y +| 1 },
            };
            var data: termio.Message.WriteReq.Small.Array = undefined;
            const resp = try std.fmt.bufPrint(
                &data,
                "\x1B[<0;{d};{d}M",
                .{ pos_vp.x + 1, y },
            );

            // Not that noisy since this only happens on prompt clicks.
            log.debug(
                "sending click_events={} event=ESC{s}",
                .{ key, 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 (self.config.link_osc8 and
        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,
        // Respect semantic prompt boundaries so link/path matching doesn't
        // merge shell prompt content with the text beside it.
        .semantic_prompt_boundary = true,
    }) orelse return null;

    const strmap = try screen.selectionStringMap(self.alloc, .{
        .sel = line,
        .trim = false,
    });
    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 = .osc8, .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(
        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.page();
    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;

    // A deep press is pressure-sensitive pointer input, such as macOS force
    // click / deep click on a trackpad, that occurs while the left mouse
    // button is already down. Treat it as the platform text-selection
    // affordance: select the pressed word, then consume the active gesture so
    // further cursor motion doesn't drag the selection.
    const left_idx = @intFromEnum(input.MouseButton.left);
    if (self.mouse.click_state[left_idx] == .press and
        stage == .deep)
    select: {
        self.renderer_state.mutex.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        const sel = self.mouse.selection_gesture.deepPress(
            self.renderer_state.terminal,
            .{ .word_boundary_codepoints = self.config.selection_word_chars },
        );

        // Deep press consumes the active drag gesture, so stop any pending
        // selection autoscroll timer that may have been started by the drag.
        if (self.selection_scroll_active) {
            self.queueIo(
                .{ .selection_scroll = false },
                .locked,
            );
        }

        try self.setSelection(sel orelse break :select);
        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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // 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.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());

    // 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.selection_gesture.left_click_count == 0) break :select;

        // If our left-click pin no longer belongs to the active screen then we
        // don't process this. We don't invalidate our pin or mouse state
        // because if the same screen switches back then we can continue our
        // selection.
        const t: *terminal.Terminal = self.renderer_state.terminal;
        if (self.mouse.activeLeftClickPin(&t.screens) == null) break :select;

        // All roads lead to requiring a re-render at this point.
        try self.queueRender();

        // 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;
        };

        // Perform our drag behavior in our gesture handler.
        const drag_selection = self.mouse.selection_gesture.drag(t, .{
            .pin = pin,
            .xpos = pos.x,
            .ypos = pos.y,
            .rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
            .word_boundary_codepoints = self.config.selection_word_chars,
            .geometry = .{
                .columns = @intCast(self.size.grid().columns),
                .cell_width = self.size.cell.width,
                .padding_left = self.size.padding.left,
                .screen_height = self.size.screen.height,
            },
        });

        // Update our autoscroll timer based on the gesture state
        switch (self.mouse.selection_gesture.left_drag_autoscroll) {
            .none => if (self.selection_scroll_active) {
                self.queueIo(
                    .{ .selection_scroll = false },
                    .locked,
                );
            },
            .up, .down => if (!self.selection_scroll_active) {
                self.queueIo(
                    .{ .selection_scroll = true },
                    .locked,
                );
            },
        }

        // Update our selection based on the gesture state
        try self.setSelection(drag_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.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());
                self.scrollToBottom() catch |err| {
                    log.warn("error scrolling to bottom err={}", .{err});
                };
            }
        },

        .text => |data| {
            var stack = std.heap.stackFallback(256, self.alloc);
            const alloc = stack.get();
            const buf = try alloc.alloc(u8, data.len);
            defer 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.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());
                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.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());

                // 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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());
            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(global.io(), "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(
                global.io(),
                .{ .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(
                global.io(),
                .{ .select = switch (nav) {
                    .next => .next,
                    .previous => .prev,
                } },
                .forever,
            );
            s.state.wakeup.notify() catch {};
        },

        .copy_to_clipboard => |format| {
            self.renderer_state.mutex.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());

            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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());
            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 = .standard },
        )) == .started,

        .paste_from_selection => return (try self.startClipboardRequest(
            .selection,
            .{ .paste = .selection },
        )) == .started,

        .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,
        ),

        .prompt_window_title => return try self.rt_app.performAction(
            .{ .surface = self },
            .prompt_title,
            .window,
        ),

        .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 },
            );
        },

        .set_window_title => |v| {
            const title = try self.alloc.dupeZ(u8, v);
            defer self.alloc.free(title);
            return try self.rt_app.performAction(
                .{ .surface = self },
                .set_window_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.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());
                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.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());
                const t: *terminal.Terminal = self.renderer_state.terminal;
                t.screens.active.scroll(.{ .row = n });
            }

            try self.queueRender();
        },

        .scroll_to_selection => {
            {
                self.renderer_state.mutex.lockUncancelable(global.io());
                defer self.renderer_state.mutex.unlock(global.io());
                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 },
        ),

        .move_tab_to_new_window => return try self.rt_app.performAction(
            .{ .surface = self },
            .move_tab_to_new_window,
            {},
        ),

        .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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());

            const sel = self.io.terminal.screens.active.selectAll();
            if (sel) |s| {
                try self.setSelectionAndCopy(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(global.io(), .{ .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.lockUncancelable(global.io());
            defer self.renderer_state.mutex.unlock(global.io());

            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();
    var retain_tmp_dir = false;
    defer if (retain_tmp_dir) tmp_dir.close(.retain) else 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(
        global.io(),
        filename,
        switch (builtin.os.tag) {
            .windows => .{},
            else => .{ .permissions = .fromMode(0o600) },
        },
    );
    defer file.close(global.io());

    // Screen.dumpString writes byte-by-byte, so buffer it
    var buf: [4096]u8 = undefined;
    var file_writer = file.writer(global.io(), &buf);
    var buf_writer = &file_writer.interface;

    // Write the scrollback contents. This requires a lock.
    {
        self.renderer_state.mutex.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());

        // 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.
            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 = path_buf[0..try tmp_dir.dir.realPathFile(
        global.io(),
        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),
    }

    // The action accepted the path, so retain the file for its consumer.
    retain_tmp_dir = true;
}

/// The payload for completing a clipboard request with
/// completeClipboardRequest.
pub const CompleteClipboard = struct {
    /// The representations the apprt could serve for the request's MIME
    /// types. These are immediately copied as needed so they only need
    /// to live for the duration of the completion call. Requesters that
    /// only carry text (paste, OSC 52) use the first text-like
    /// representation.
    contents: []const terminal.clipboard.Content = &.{},

    /// The listing of MIME types available on the clipboard, only
    /// gathered when the request asked for it.
    available: []const []const u8 = &.{},

    /// True if any clipboard confirmation prompt was already answered
    /// by the user, skipping further prompts:
    ///
    ///   - 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 and Kitty clipboard protocol reads and writes no
    ///     prompt is shown to the user when this is true.
    confirmed: bool = false,

    /// True if the user asked to remember their decision. This is only
    /// honored by request types that support session grants (Kitty
    /// clipboard protocol requests carrying a password).
    remember: bool = false,
};

/// Call this to complete a clipboard request sent to apprt. This should
/// only be called once for each request.
///
/// If `complete.confirmed` is false then this may return either an
/// UnsafePaste or UnauthorizedPaste error, depending on the type of
/// clipboard request. The request state remains alive in that case so
/// the apprt can run its confirmation flow.
pub fn completeClipboardRequest(
    self: *Surface,
    req: apprt.ClipboardRequest,
    complete: CompleteClipboard,
) !void {
    switch (req) {
        .paste => try self.completeClipboardPaste(
            clipboardTextContent(complete.contents) orelse "",
            complete.confirmed,
        ),

        .list => |clipboard| if (!try self.completeClipboardPasteEvent(
            clipboard,
            complete.available,
        )) {
            log.debug("mode 5522 paste event was not written", .{});
        },

        .osc_52_read => |clipboard| try self.completeClipboardReadOSC52(
            clipboardTextContent(complete.contents) orelse "",
            clipboard,
            complete.confirmed,
        ),

        .osc_52_write => |clipboard| {
            // The write API wants sentinel-terminated data; the write
            // text round-tripped through the apprt confirmation flow as
            // a plain representation.
            const data = try self.alloc.dupeZ(
                u8,
                clipboardTextContent(complete.contents) orelse "",
            );
            defer self.alloc.free(data);
            try self.rt_surface.setClipboard(clipboard, &.{.{
                .mime = "text/plain",
                .data = data,
            }}, !complete.confirmed);
        },

        .kitty_read => |kitty| {
            // If we need confirmation we return an error without
            // consuming the request state; the apprt keeps it alive
            // for the confirmation flow. A session grant carried by
            // the request skips the prompt, and a request with no
            // data types is exempt from prompting entirely; see
            // readPromptExempt.
            if (self.config.clipboard_read == .ask and
                !complete.confirmed and
                !kitty.granted and
                !terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len))
            {
                return error.UnauthorizedPaste;
            }

            // Past the confirmation check the request is consumed:
            // every path from here, including errors, must destroy it.
            defer kitty.destroy();

            // Record a session grant when the user asked to remember
            // their decision and the request carried a usable
            // password. The grants live with the terminal state on
            // the IO thread.
            if (complete.remember and kitty.pw.len > 0) {
                const pw = try self.alloc.dupe(u8, kitty.pw);
                self.queueIo(.{ .kitty_clipboard_grant_read = .{
                    .alloc = self.alloc,
                    .pw = pw,
                } }, .unlocked);
            }

            try self.completeKittyClipboardRead(
                kitty,
                complete.contents,
                complete.available,
            );
        },

        .kitty_write => |kitty| {
            // If we need confirmation we return an error without
            // consuming the request state; the apprt keeps it alive
            // for the confirmation flow. A session grant carried by
            // the request skips the prompt.
            if (self.config.clipboard_write == .ask and
                !complete.confirmed and
                !kitty.granted)
            {
                return error.UnauthorizedPaste;
            }

            // Past the confirmation check the request is consumed:
            // every path from here, including errors, must destroy it.
            defer kitty.destroy();

            // Record a session grant when the user asked to remember
            // their decision and the request carried a usable
            // password. The grants live with the terminal state on
            // the IO thread.
            if (complete.remember and kitty.pw.len > 0) {
                const pw = try self.alloc.dupe(u8, kitty.pw);
                self.queueIo(.{ .kitty_clipboard_grant_write = .{
                    .alloc = self.alloc,
                    .pw = pw,
                } }, .unlocked);
            }

            // Apply the committed representations carried by the
            // request itself; any contents echoed back by the apprt
            // are only what its confirmation prompt displayed. An
            // empty commit clears the clipboard, which the apprt
            // write API expresses as a single empty text entry.
            self.rt_surface.setClipboard(
                kitty.location,
                if (kitty.contents.len > 0) kitty.contents else &.{.{
                    .mime = "text/plain",
                    .data = "",
                }},
                false,
            ) catch |err| {
                log.err("error setting clipboard err={}", .{err});
                try self.kittyClipboardStatus(
                    .write,
                    kitty.id,
                    kitty.terminator,
                    .EIO,
                );
                return;
            };

            try self.kittyClipboardStatus(
                .write,
                kitty.id,
                kitty.terminator,
                .DONE,
            );
        },
    }
}

/// The first text-like representation of the contents, if any.
fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 {
    for (contents) |content| {
        if (terminal.clipboard.isTextMime(content.mime)) return content.data;
    }
    return null;
}

/// Deny an in-flight clipboard request. This consumes the request: for
/// request types whose protocol expects an answer, the denial reply is
/// written to the pty.
pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void {
    switch (req) {
        // A denied paste simply doesn't happen.
        .paste, .list => {},

        // OSC 52 has no error responses, but the client is waiting on
        // a reply, so a denied read is answered with empty contents.
        .osc_52_read => |clipboard| self.completeClipboardReadOSC52(
            "",
            clipboard,
            true,
        ) catch |err| {
            log.warn("error replying to OSC 52 clipboard read err={}", .{err});
        },

        // A denied write simply doesn't happen.
        .osc_52_write => {},

        // The Kitty clipboard protocol reports denial explicitly.
        .kitty_read => |kitty| {
            defer kitty.destroy();
            self.kittyClipboardStatus(
                .read,
                kitty.id,
                kitty.terminator,
                .EPERM,
            ) catch |err| {
                log.warn("error replying to kitty clipboard read err={}", .{err});
            };
        },

        .kitty_write => |kitty| {
            defer kitty.destroy();
            self.kittyClipboardStatus(
                .write,
                kitty.id,
                kitty.terminator,
                .EPERM,
            ) catch |err| {
                log.warn("error replying to kitty clipboard write err={}", .{err});
            };
        },
    }
}

/// This starts a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
///
/// The result reports whether the request was started; requests that
/// weren't started never complete. Callers own reacting to that, e.g.
/// performable paste keybinds pass through and Kitty clipboard reads
/// answer the program.
fn startClipboardRequest(
    self: *Surface,
    loc: apprt.Clipboard,
    req: apprt.ClipboardRequest,
) !apprt.ClipboardReadResult {
    const effective_req: apprt.ClipboardRequest = switch (req) {
        .paste => |clipboard| effective: {
            // Snapshot the mode before asking the apprt for clipboard data.
            // Event pastes request only a MIME listing, while ordinary
            // pastes request the text representation as before.
            self.renderer_state.mutex.lockUncancelable(global.io());
            const event = self.io.terminal.modes.get(.kitty_paste_events);
            self.renderer_state.mutex.unlock(global.io());

            break :effective if (event)
                .{ .list = clipboard }
            else
                req;
        },
        else => req,
    };

    switch (effective_req) {
        .paste, .list => {}, // 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 .unsupported;
        },

        // The clipboard access policies were already applied by
        // kittyClipboardRead and kittyClipboardWrite, which own
        // replying on denial.
        .kitty_read, .kitty_write => {},

        // OSC 52 writes don't travel through this function; they go
        // straight to the apprt setClipboard API.
        .osc_52_write => unreachable,
    }

    return try self.rt_surface.clipboardRequest(loc, effective_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.lockUncancelable(global.io());
        defer self.renderer_state.mutex.unlock(global.io());
        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);
    };
}

/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled.
/// The event only lists the available MIME types; it does not read any of
/// their data. The shared terminal paste implementation generates and records
/// the one-time password used by the program's follow-up OSC 5522 read.
fn completeClipboardPasteEvent(
    self: *Surface,
    clipboard: apprt.Clipboard,
    available: []const []const u8,
) !bool {
    if (self.readonly) return false;

    const kitty_clipboard = terminal.kitty.clipboard;
    const location: terminal.clipboard.Location = switch (clipboard) {
        .standard => .standard,
        .selection => .selection,
        .primary => .primary,
    };

    // The protocol implementation caps listings at this size too. Cap here
    // so the temporary Content array stays on the stack.
    var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined;
    const contents_len = @min(available.len, contents_buf.len);
    for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| {
        content.* = .{ .mime = mime, .data = "" };
    }

    var aw: std.Io.Writer.Allocating = .init(self.alloc);
    defer aw.deinit();

    self.renderer_state.mutex.lockUncancelable(global.io());
    defer self.renderer_state.mutex.unlock(global.io());

    const pasted = try terminal.paste.paste(.{
        .terminal = &self.io.terminal,
        .alloc = self.alloc,
        .writer = &aw.writer,
        .kitty_clipboard = .{
            .grants = &self.io.terminal_stream.handler.kitty_clipboard_grants,
            .io = global.io(),
        },
    }, .{
        .source = .{ .clipboard = location },
        .contents = .{ .memory = contents_buf[0..contents_len] },
        // A paste event discloses no clipboard data, so unsafe-text
        // confirmation does not apply. If mode 5522 is reset, the empty
        // stand-in representations cause the shared helper to write nothing.
        .allow_unsafe = true,
    });
    if (!pasted) return false;

    self.queueIo(.{ .write_alloc = .{
        .alloc = self.alloc,
        .data = try aw.toOwnedSlice(),
    } }, .locked);
    return true;
}

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);
    const buf = try self.alloc.alloc(u8, size + 9); // const for OSC
    errdefer 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(.{ .write_alloc = .{
        .alloc = self.alloc,
        .data = buf,
    } }, .unlocked);
}

/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded
/// by the IO thread. This takes ownership of the request state.
fn kittyClipboardRead(
    self: *Surface,
    req: *apprt.ClipboardRequest.KittyRead,
) !void {
    // A read denied by policy answers EPERM so clients degrade
    // gracefully instead of waiting on a response that never comes.
    if (self.config.clipboard_read == .deny) {
        defer req.destroy();
        log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{});
        try self.kittyClipboardStatus(.read, req.id, req.terminator, .EPERM);
        return;
    }

    const result = self.startClipboardRequest(
        req.location,
        .{ .kitty_read = req },
    ) catch |err| {
        defer req.destroy();
        self.kittyClipboardStatus(.read, req.id, req.terminator, .EIO) catch {};
        return err;
    };

    switch (result) {
        // The request completes asynchronously.
        .started => {},

        // The clipboard has nothing we can serve, which is a
        // successful read that serves no representations. This never
        // prompts even under an ask policy since there are no contents
        // to disclose.
        .unavailable => {
            defer req.destroy();
            try self.completeKittyClipboardRead(req, &.{}, &.{});
        },

        // The apprt can't serve this clipboard at all, e.g. an
        // unsupported primary selection.
        .unsupported => {
            defer req.destroy();
            try self.kittyClipboardStatus(.read, req.id, req.terminator, .ENOSYS);
        },
    }
}

/// Handle a committed Kitty clipboard protocol (OSC 5522) write
/// transaction forwarded by the IO thread. This takes ownership of the
/// request state.
fn kittyClipboardWrite(
    self: *Surface,
    req: *apprt.ClipboardRequest.KittyWrite,
) !void {
    // A write denied by policy answers EPERM so clients degrade
    // gracefully instead of waiting on a response that never comes.
    // The IO thread already fails transactions that begin under a
    // deny policy, but the policy may have changed mid-transaction.
    if (self.config.clipboard_write == .deny) {
        defer req.destroy();
        log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{});
        try self.kittyClipboardStatus(.write, req.id, req.terminator, .EPERM);
        return;
    }

    const result = self.startClipboardRequest(
        req.location,
        .{ .kitty_write = req },
    ) catch |err| {
        defer req.destroy();
        self.kittyClipboardStatus(.write, req.id, req.terminator, .EIO) catch {};
        return err;
    };

    switch (result) {
        // The request completes asynchronously.
        .started => {},

        // The apprt can't write this clipboard at all, e.g. an
        // unsupported primary selection. Writes carry their own
        // contents so there is no meaningful unavailable state; treat
        // it the same.
        .unavailable, .unsupported => {
            defer req.destroy();
            try self.kittyClipboardStatus(.write, req.id, req.terminator, .ENOSYS);
        },
    }
}

/// Reply to a Kitty clipboard request with a single status packet.
fn kittyClipboardStatus(
    self: *Surface,
    op: terminal.kitty.clipboard.Operation,
    id: []const u8,
    terminator: terminal.osc.Terminator,
    status: terminal.kitty.clipboard.Status,
) error{ OutOfMemory, WriteFailed }!void {
    var aw: std.Io.Writer.Allocating = .init(self.alloc);
    defer aw.deinit();
    try (terminal.kitty.clipboard.Response{
        .op = op,
        .status = status,
        .id = id,
        .terminator = terminator,
    }).encode(&aw.writer);

    self.queueIo(.{ .write_alloc = .{
        .alloc = self.alloc,
        .data = try aw.toOwnedSlice(),
    } }, .unlocked);
}

/// Complete a Kitty clipboard protocol read with the clipboard
/// contents.
fn completeKittyClipboardRead(
    self: *Surface,
    req: *const apprt.ClipboardRequest.KittyRead,
    contents: []const terminal.clipboard.Content,
    available: []const []const u8,
) !void {
    const kitty_clipboard = terminal.kitty.clipboard;

    // Serve the requested representations in request order under their
    // requested names. Text-like MIME aliases all match the canonical
    // text representation, since that is the only name the apprt
    // serves text under. Requested types without a representation are
    // simply never served, which is how the protocol communicates an
    // unavailable representation.
    var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined;
    var contents_len: usize = 0;
    for (req.mimes) |mime| {
        const data: []const u8 = data: {
            for (contents) |content| {
                if (std.mem.eql(u8, content.mime, mime)) break :data content.data;
                if (terminal.clipboard.isTextMime(mime) and
                    terminal.clipboard.isTextMime(content.mime))
                {
                    break :data content.data;
                }
            }

            continue;
        };

        contents_buf[contents_len] = .{ .mime = mime, .data = data };
        contents_len += 1;
    }

    // Encode the full success sequence: the OK packet, the targets
    // listing if it was requested, DATA chunks for each served
    // representation, and the final DONE packet.
    var aw: std.Io.Writer.Allocating = .init(self.alloc);
    defer aw.deinit();
    try (kitty_clipboard.ReadSuccess{
        .primary = req.location == .primary,
        .id = req.id,
        .list = req.list,
        .available = available,
        .contents = contents_buf[0..contents_len],
        .terminator = req.terminator,
    }).encode(&aw.writer);

    self.queueIo(.{ .write_alloc = .{
        .alloc = self.alloc,
        .data = try aw.toOwnedSlice(),
    } }, .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: std.Io.Timestamp = .now(global.io(), .awake);

    // 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 (last.durationTo(now).toSeconds() < 1) {
            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 (last.durationTo(now).toSeconds() < 5) {
                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,
        {},
    );
}

/// 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 "queueIo frees allocated writes in readonly mode" {
    const testing = std.testing;

    const surface = try testing.allocator.create(Surface);
    defer testing.allocator.destroy(surface);
    surface.readonly = true;

    // queueIo must free allocated writes in read-only mode.
    const data = try testing.allocator.dupe(u8, "\x1b]lGhostty\x1b\\");
    surface.queueIo(.{ .write_alloc = .{
        .alloc = testing.allocator,
        .data = data,
    } }, .unlocked);
}
