//! 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 = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const renderer = @import("renderer.zig");
const termio = @import("termio.zig");
const objc = @import("objc");
const imgui = @import("imgui");
const Pty = @import("Pty.zig");
const font = @import("font/main.zig");
const Command = @import("Command.zig");
const trace = @import("tracy").trace;
const terminal = @import("terminal/main.zig");
const configpkg = @import("config.zig");
const input = @import("input.zig");
const App = @import("App.zig");
const internal_os = @import("os/main.zig");

const log = std.log.scoped(.surface);

// The renderer implementation to use.
const Renderer = renderer.Renderer;

/// 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_lib: font.Library,
font_group: *font.GroupCache,
font_size: font.face.DesiredSize,

/// The renderer for this surface.
renderer: Renderer,

/// The render state
renderer_state: renderer.State,

/// The renderer thread manager
renderer_thread: renderer.Thread,

/// The actual thread
renderer_thr: std.Thread,

/// Mouse state.
mouse: Mouse,

/// The terminal IO handler.
io: termio.Impl,
io_thread: termio.Thread,
io_thr: std.Thread,

/// All the cached sizes since we need them at various times.
screen_size: renderer.ScreenSize,
grid_size: renderer.GridSize,
cell_size: renderer.CellSize,

/// Explicit padding due to configuration
padding: renderer.Padding,

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

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

/// Mouse state for the surface.
const Mouse = struct {
    /// The last tracked mouse button state by button.
    click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max,

    /// The last mods state when the last mouse button (whatever it was) was
    /// pressed or release.
    mods: input.Mods = .{},

    /// The point at which the left mouse click happened. This is in screen
    /// coordinates so that scrolling preserves the location.
    left_click_point: terminal.point.ScreenPoint = .{},

    /// The starting xpos/ypos of the left click. Note that if scrolling occurs,
    /// these will point to different "cells", but the xpos/ypos will stay
    /// stable during scrolling relative to the surface.
    left_click_xpos: f64 = 0,
    left_click_ypos: f64 = 0,

    /// The count of clicks to count double and triple clicks and so on.
    /// The left click time was the last time the left click was done. This
    /// is always set on the first left click.
    left_click_count: u8 = 0,
    left_click_time: std.time.Instant = undefined,

    /// The last x/y sent for mouse reports.
    event_point: ?terminal.point.Viewport = null,

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

/// 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: u8,
    keybind: configpkg.Keybinds,
    clipboard_read: bool,
    clipboard_write: bool,
    clipboard_trim_trailing_spaces: bool,
    copy_on_select: configpkg.CopyOnSelect,
    confirm_close_surface: bool,
    mouse_interval: u64,
    mouse_hide_while_typing: bool,
    macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
    macos_option_as_alt: configpkg.OptionAsAlt,
    window_padding_x: u32,
    window_padding_y: u32,

    pub fn init(alloc_gpa: Allocator, config: *const configpkg.Config) !DerivedConfig {
        var arena = ArenaAllocator.init(alloc_gpa);
        errdefer arena.deinit();
        const alloc = arena.allocator();

        return .{
            .original_font_size = config.@"font-size",
            .keybind = try config.keybind.clone(alloc),
            .clipboard_read = config.@"clipboard-read",
            .clipboard_write = config.@"clipboard-write",
            .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces",
            .copy_on_select = config.@"copy-on-select",
            .confirm_close_surface = config.@"confirm-close-surface",
            .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms
            .mouse_hide_while_typing = config.@"mouse-hide-while-typing",
            .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen",
            .macos_option_as_alt = config.@"macos-option-as-alt",
            .window_padding_x = config.@"window-padding-x",
            .window_padding_y = config.@"window-padding-y",

            // 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 {
        self.arena.deinit();
    }
};

/// 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: *const configpkg.Config,
    app: *App,
    rt_app: *apprt.runtime.App,
    rt_surface: *apprt.runtime.Surface,
) !void {
    // 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),
    };

    // Find all the fonts for this surface
    //
    // Future: we can share the font group amongst all surfaces to save
    // some new surface init time and some memory. This will require making
    // thread-safe changes to font structs.
    var font_lib = try font.Library.init();
    errdefer font_lib.deinit();
    var font_group = try alloc.create(font.GroupCache);
    errdefer alloc.destroy(font_group);
    font_group.* = try font.GroupCache.init(alloc, group: {
        var group = try font.Group.init(alloc, font_lib, font_size);
        errdefer group.deinit();

        // Setup our font metric modifiers if we have any.
        group.metric_modifiers = set: {
            var set: font.face.Metrics.ModifierSet = .{};
            errdefer set.deinit(alloc);
            if (config.@"adjust-cell-width") |m| try set.put(alloc, .cell_width, m);
            if (config.@"adjust-cell-height") |m| try set.put(alloc, .cell_height, m);
            if (config.@"adjust-font-baseline") |m| try set.put(alloc, .cell_baseline, m);
            if (config.@"adjust-underline-position") |m| try set.put(alloc, .underline_position, m);
            if (config.@"adjust-underline-thickness") |m| try set.put(alloc, .underline_thickness, m);
            if (config.@"adjust-strikethrough-position") |m| try set.put(alloc, .strikethrough_position, m);
            if (config.@"adjust-strikethrough-thickness") |m| try set.put(alloc, .strikethrough_thickness, m);
            break :set set;
        };

        // If we have codepoint mappings, set those.
        if (config.@"font-codepoint-map".map.list.len > 0) {
            group.codepoint_map = config.@"font-codepoint-map".map;
        }

        // Set our styles
        group.styles.set(.bold, config.@"font-style-bold" != .false);
        group.styles.set(.italic, config.@"font-style-italic" != .false);
        group.styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);

        // Search for fonts
        if (font.Discover != void) discover: {
            const disco = try app.fontDiscover() orelse {
                log.warn("font discovery not available, cannot search for fonts", .{});
                break :discover;
            };
            group.discover = disco;

            // A buffer we use to store the font names for logging.
            var name_buf: [256]u8 = undefined;

            if (config.@"font-family") |family| {
                var disco_it = try disco.discover(alloc, .{
                    .family = family,
                    .style = config.@"font-style".nameValue(),
                    .size = font_size.points,
                    .variations = config.@"font-variation".list.items,
                });
                defer disco_it.deinit();
                if (try disco_it.next()) |face| {
                    log.info("font regular: {s}", .{try face.name(&name_buf)});
                    _ = try group.addFace(.regular, .{ .deferred = face });
                } else log.warn("font-family not found: {s}", .{family});
            }
            if (config.@"font-family-bold") |family| {
                var disco_it = try disco.discover(alloc, .{
                    .family = family,
                    .style = config.@"font-style-bold".nameValue(),
                    .size = font_size.points,
                    .bold = true,
                    .variations = config.@"font-variation-bold".list.items,
                });
                defer disco_it.deinit();
                if (try disco_it.next()) |face| {
                    log.info("font bold: {s}", .{try face.name(&name_buf)});
                    _ = try group.addFace(.bold, .{ .deferred = face });
                } else log.warn("font-family-bold not found: {s}", .{family});
            }
            if (config.@"font-family-italic") |family| {
                var disco_it = try disco.discover(alloc, .{
                    .family = family,
                    .style = config.@"font-style-italic".nameValue(),
                    .size = font_size.points,
                    .italic = true,
                    .variations = config.@"font-variation-italic".list.items,
                });
                defer disco_it.deinit();
                if (try disco_it.next()) |face| {
                    log.info("font italic: {s}", .{try face.name(&name_buf)});
                    _ = try group.addFace(.italic, .{ .deferred = face });
                } else log.warn("font-family-italic not found: {s}", .{family});
            }
            if (config.@"font-family-bold-italic") |family| {
                var disco_it = try disco.discover(alloc, .{
                    .family = family,
                    .style = config.@"font-style-bold-italic".nameValue(),
                    .size = font_size.points,
                    .bold = true,
                    .italic = true,
                    .variations = config.@"font-variation-bold-italic".list.items,
                });
                defer disco_it.deinit();
                if (try disco_it.next()) |face| {
                    log.info("font bold+italic: {s}", .{try face.name(&name_buf)});
                    _ = try group.addFace(.bold_italic, .{ .deferred = face });
                } else log.warn("font-family-bold-italic not found: {s}", .{family});
            }
        }

        // Our built-in font will be used as a backup
        _ = try group.addFace(
            .regular,
            .{ .loaded = try font.Face.init(font_lib, face_ttf, group.faceOptions()) },
        );
        _ = try group.addFace(
            .bold,
            .{ .loaded = try font.Face.init(font_lib, face_bold_ttf, group.faceOptions()) },
        );

        // Auto-italicize if we have to.
        try group.italicize();

        // Emoji fallback. We don't include this on Mac since Mac is expected
        // to always have the Apple Emoji available on the system.
        if (builtin.os.tag != .macos or font.Discover == void) {
            _ = try group.addFace(
                .regular,
                .{ .loaded = try font.Face.init(font_lib, face_emoji_ttf, group.faceOptions()) },
            );
            _ = try group.addFace(
                .regular,
                .{ .loaded = try font.Face.init(font_lib, face_emoji_text_ttf, group.faceOptions()) },
            );
        }

        break :group group;
    });
    errdefer font_group.deinit(alloc);

    log.info("font loading complete, any non-logged faces are using the built-in font", .{});

    // Pre-calculate our initial cell size ourselves.
    const cell_size = try renderer.CellSize.init(alloc, font_group);

    // Convert our padding from points to pixels
    const padding_x: u32 = padding_x: {
        const padding_x: f32 = @floatFromInt(config.@"window-padding-x");
        break :padding_x @intFromFloat(@floor(padding_x * x_dpi / 72));
    };
    const padding_y: u32 = padding_y: {
        const padding_y: f32 = @floatFromInt(config.@"window-padding-y");
        break :padding_y @intFromFloat(@floor(padding_y * y_dpi / 72));
    };
    const padding: renderer.Padding = .{
        .top = padding_y,
        .bottom = padding_y,
        .right = padding_x,
        .left = padding_x,
    };

    // 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 Renderer.DerivedConfig.init(alloc, config),
        .font_group = font_group,
        .padding = .{
            .explicit = padding,
            .balance = config.@"window-padding-balance",
        },
        .surface_mailbox = .{ .surface = self, .app = app_mailbox },
    });
    errdefer renderer_impl.deinit();

    // Calculate our grid size based on known dimensions.
    const surface_size = try rt_surface.getSize();
    const screen_size: renderer.ScreenSize = .{
        .width = surface_size.width,
        .height = surface_size.height,
    };
    const grid_size = renderer.GridSize.init(
        screen_size.subPadding(padding),
        cell_size,
    );

    // The mutex used to protect our renderer state.
    var mutex = try alloc.create(std.Thread.Mutex);
    mutex.* = .{};
    errdefer alloc.destroy(mutex);

    // Create the renderer thread
    var render_thread = try renderer.Thread.init(
        alloc,
        rt_surface,
        &self.renderer,
        &self.renderer_state,
        app_mailbox,
    );
    errdefer render_thread.deinit();

    // Start our IO implementation
    var io = try termio.Impl.init(alloc, .{
        .grid_size = grid_size,
        .screen_size = screen_size,
        .padding = padding,
        .full_config = config,
        .config = try termio.Impl.DerivedConfig.init(alloc, config),
        .resources_dir = app.resources_dir,
        .renderer_state = &self.renderer_state,
        .renderer_wakeup = render_thread.wakeup,
        .renderer_mailbox = render_thread.mailbox,
        .surface_mailbox = .{ .surface = self, .app = app_mailbox },
    });
    errdefer io.deinit();

    // Create the IO thread
    var io_thread = try termio.Thread.init(alloc, &self.io);
    errdefer io_thread.deinit();

    self.* = .{
        .alloc = alloc,
        .app = app,
        .rt_app = rt_app,
        .rt_surface = rt_surface,
        .font_lib = font_lib,
        .font_group = font_group,
        .font_size = font_size,
        .renderer = renderer_impl,
        .renderer_thread = render_thread,
        .renderer_state = .{
            .mutex = mutex,
            .terminal = &self.io.terminal,
        },
        .renderer_thr = undefined,
        .mouse = .{},
        .io = io,
        .io_thread = io_thread,
        .io_thr = undefined,
        .screen_size = .{ .width = 0, .height = 0 },
        .grid_size = .{},
        .cell_size = cell_size,
        .padding = padding,
        .config = try DerivedConfig.init(alloc, config),
    };

    // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
    // but is otherwise somewhat arbitrary.
    try rt_surface.setSizeLimits(.{
        .width = cell_size.width * 10,
        .height = cell_size.height * 4,
    }, null);

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

    // 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(
        .{},
        renderer.Thread.threadMain,
        .{&self.renderer_thread},
    );
    self.renderer_thr.setName("renderer") catch {};

    // Start our IO thread
    self.io_thr = try std.Thread.spawn(
        .{},
        termio.Thread.threadMain,
        .{&self.io_thread},
    );
    self.io_thr.setName("io") catch {};

    // Determine our initial window size if configured. We need to do this
    // quite late in the process because our height/width are in grid dimensions,
    // so we need to know our cell sizes first.
    //
    // Note: it is important to do this after the renderer is setup above.
    // This allows the apprt to fully initialize the surface before we
    // start messing with the window.
    if (config.@"window-height" > 0 and config.@"window-width" > 0) init: {
        const scale = rt_surface.getContentScale() catch break :init;
        const height = @max(config.@"window-height" * cell_size.height, 480);
        const width = @max(config.@"window-width" * cell_size.width, 640);
        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))) +
            padding.left +
            padding.right;
        const final_height: u32 =
            @as(u32, @intFromFloat(@ceil(height_f32 / scale.y))) +
            padding.top +
            padding.bottom;

        rt_surface.setInitialWindowSize(final_width, final_height) catch |err| {
            log.warn("unable to set initial window size: {s}", .{err});
        };
    }
}

pub fn deinit(self: *Surface) void {
    // Stop rendering thread
    {
        self.renderer_thread.stop.notify() catch |err|
            log.err("error notifying renderer thread to stop, may stall err={}", .{err});
        self.renderer_thr.join();

        // We need to become the active rendering thread again
        self.renderer.threadEnter(self.rt_surface) catch unreachable;
    }

    // Stop our IO thread
    {
        self.io_thread.stop.notify() catch |err|
            log.err("error notifying io thread to stop, may stall err={}", .{err});
        self.io_thr.join();
    }

    // We need to deinit AFTER everything is stopped, since there are
    // shared values between the two threads.
    self.renderer_thread.deinit();
    self.renderer.deinit();
    self.io_thread.deinit();
    self.io.deinit();

    self.font_group.deinit(self.alloc);
    self.font_lib.deinit();
    self.alloc.destroy(self.font_group);

    self.alloc.destroy(self.renderer_state.mutex);
    self.config.deinit();
    log.info("surface closed addr={x}", .{@intFromPtr(self)});
}

/// Close this surface. This will trigger the runtime to start the
/// close process, which should ultimately deinitialize this surface.
pub fn close(self: *Surface) void {
    self.rt_surface.close(self.needsConfirmQuit());
}

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

    // If we are configured to not hold open surfaces explicitly, just
    // always say there is nothing alive.
    if (!self.config.confirm_close_surface) return false;

    // We have to talk to the terminal.
    self.renderer_state.mutex.lock();
    defer self.renderer_state.mutex.unlock();
    return !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.changeConfig(config),

        .set_title => |*v| {
            // 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_surface.setTitle(slice);
        },

        .set_mouse_shape => |shape| {
            log.debug("changing mouse shape: {}", .{shape});
            try self.rt_surface.setMouseShape(shape);
        },

        .cell_size => |size| try self.setCellSize(size),

        .clipboard_read => |kind| {
            if (!self.config.clipboard_read) {
                log.info("application attempted to read clipboard, but 'clipboard-read' setting is off", .{});
                return;
            }

            try self.startClipboardRequest(.standard, .{ .osc_52 = kind });
        },

        .clipboard_write => |req| switch (req) {
            .small => |v| try self.clipboardWrite(v.data[0..v.len], .standard),
            .stable => |v| try self.clipboardWrite(v, .standard),
            .alloc => |v| {
                defer v.alloc.free(v.data);
                try self.clipboardWrite(v.data, .standard);
            },
        },

        .close => self.close(),

        // Close without confirmation.
        .child_exited => {
            self.child_exited = true;
            self.close();
        },
    }
}

/// Update our configuration at runtime.
fn changeConfig(self: *Surface, config: *const configpkg.Config) !void {
    // 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();
    }

    // We need to store our configs in a heap-allocated pointer so that
    // our messages aren't huge.
    var renderer_config_ptr = try self.alloc.create(Renderer.DerivedConfig);
    errdefer self.alloc.destroy(renderer_config_ptr);
    var termio_config_ptr = try self.alloc.create(termio.Impl.DerivedConfig);
    errdefer self.alloc.destroy(termio_config_ptr);

    // Update our derived configurations for the renderer and termio,
    // then send them a message to update.
    renderer_config_ptr.* = try Renderer.DerivedConfig.init(self.alloc, config);
    errdefer renderer_config_ptr.deinit();
    termio_config_ptr.* = try termio.Impl.DerivedConfig.init(self.alloc, config);
    errdefer termio_config_ptr.deinit();
    _ = self.renderer_thread.mailbox.push(.{
        .change_config = .{
            .alloc = self.alloc,
            .ptr = renderer_config_ptr,
        },
    }, .{ .forever = {} });
    _ = self.io_thread.mailbox.push(.{
        .change_config = .{
            .alloc = self.alloc,
            .ptr = termio_config_ptr,
        },
    }, .{ .forever = {} });

    // 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});
    };
    self.io_thread.wakeup.notify() catch |err| {
        log.warn("failed to notify io thread of config change err={}", .{err});
    };
}

/// 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) !?[]const u8 {
    self.renderer_state.mutex.lock();
    defer self.renderer_state.mutex.unlock();
    const terminal_pwd = self.io.terminal.getPwd() orelse return null;
    return try alloc.dupe(u8, terminal_pwd);
}

/// Returns the x/y coordinate of where the IME (Input Method Editor)
/// keyboard should be rendered.
pub fn imePoint(self: *const Surface) apprt.IMEPos {
    self.renderer_state.mutex.lock();
    const cursor = self.renderer_state.terminal.screen.cursor;
    self.renderer_state.mutex.unlock();

    // TODO: need to handle when scrolling and the cursor is not
    // in the visible portion of the screen.

    // Our sizes are all scaled so we need to send the unscaled values back.
    const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };

    const x: f64 = x: {
        // Simple x * cell width gives the top-left corner
        var x: f64 = @floatFromInt(cursor.x * self.cell_size.width);

        // We want the midpoint
        x += @as(f64, @floatFromInt(self.cell_size.width)) / 2;

        // And scale it
        x /= content_scale.x;

        break :x x;
    };

    const y: f64 = y: {
        // Simple x * cell width gives the top-left corner
        var y: f64 = @floatFromInt(cursor.y * self.cell_size.height);

        // We want the bottom
        y += @floatFromInt(self.cell_size.height);

        // And scale it
        y /= content_scale.y;

        break :y y;
    };

    return .{ .x = x, .y = y };
}

fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void {
    if (!self.config.clipboard_write) {
        log.info("application attempted to write clipboard, but 'clipboard-write' setting is off", .{});
        return;
    }

    const dec = std.base64.standard.Decoder;

    // Build buffer
    const size = dec.calcSizeForSlice(data) catch |err| switch (err) {
        error.InvalidPadding => {
            log.info("application sent invalid base64 data for OSC 52", .{});
            return;
        },

        // Should not be reachable but don't want to risk it.
        else => return,
    };
    var buf = try self.alloc.allocSentinel(u8, size, 0);
    defer self.alloc.free(buf);
    buf[buf.len] = 0;

    // Decode
    dec.decode(buf, data) catch |err| switch (err) {
        // Ignore this. It is possible to actually have valid data and
        // get this error, so we allow it.
        error.InvalidPadding => {},

        else => {
            log.info("application sent invalid base64 data for OSC 52", .{});
            return;
        },
    };
    assert(buf[buf.len] == 0);

    self.rt_surface.setClipboardString(buf, loc) catch |err| {
        log.err("error setting clipboard string err={}", .{err});
        return;
    };
}

/// Set the selection contents.
///
/// This must be called with the renderer mutex held.
fn setSelection(self: *Surface, sel_: ?terminal.Selection) void {
    const prev_ = self.io.terminal.screen.selection;
    self.io.terminal.screen.selection = sel_;

    // Determine the clipboard we want to copy selection to, if it is enabled.
    const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) {
        .false => return,
        .true => .selection,
        .clipboard => .standard,
    };

    // Set our selection clipboard. If the selection is cleared we do not
    // clear the clipboard. If the selection is set, we only set the clipboard
    // again if it changed, since setting the clipboard can be an expensive
    // operation.
    const sel = sel_ orelse return;
    if (prev_) |prev| if (std.meta.eql(sel, prev)) return;

    // Check if our runtime supports the selection clipboard at all.
    // We can save a lot of work if it doesn't.
    if (@hasDecl(apprt.runtime.Surface, "supportsClipboard")) {
        if (!self.rt_surface.supportsClipboard(clipboard)) {
            return;
        }
    }

    var buf = self.io.terminal.screen.selectionString(
        self.alloc,
        sel,
        self.config.clipboard_trim_trailing_spaces,
    ) catch |err| {
        log.err("error reading selection string err={}", .{err});
        return;
    };
    defer self.alloc.free(buf);

    self.rt_surface.setClipboardString(buf, clipboard) catch |err| {
        log.err("error setting clipboard string err={}", .{err});
        return;
    };
}

/// 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: renderer.CellSize) !void {
    // Update our new cell size for future calcs
    self.cell_size = size;

    // Update our grid_size
    self.grid_size = renderer.GridSize.init(
        self.screen_size.subPadding(self.padding),
        self.cell_size,
    );

    // Notify the terminal
    _ = self.io_thread.mailbox.push(.{
        .resize = .{
            .grid_size = self.grid_size,
            .screen_size = self.screen_size,
            .padding = self.padding,
        },
    }, .{ .forever = {} });
    self.io_thread.wakeup.notify() catch {};
}

/// Change the font size.
///
/// This can only be called from the main thread.
pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) void {
    // Update our font size so future changes work
    self.font_size = size;

    // Notify our render thread of the font size. This triggers everything else.
    _ = self.renderer_thread.mailbox.push(.{
        .font_size = size,
    }, .{ .forever = {} });

    // 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: *const Surface) !void {
    try self.renderer_thread.wakeup.notify();
}

pub fn sizeCallback(self: *Surface, size: apprt.SurfaceSize) !void {
    const tracy = trace(@src());
    defer tracy.end();

    const new_screen_size: renderer.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.screen_size.equals(new_screen_size)) return;

    try self.resize(new_screen_size);
}

fn resize(self: *Surface, size: renderer.ScreenSize) !void {
    // Save our screen size
    self.screen_size = size;

    // Mail the renderer so that it can update the GPU and re-render
    _ = self.renderer_thread.mailbox.push(.{
        .resize = .{
            .screen_size = self.screen_size,
            .padding = self.padding,
        },
    }, .{ .forever = {} });
    try self.queueRender();

    // 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.
    self.grid_size = renderer.GridSize.init(
        self.screen_size.subPadding(self.padding),
        self.cell_size,
    );
    if (self.grid_size.columns < 5 and (self.padding.left > 0 or self.padding.right > 0)) {
        log.warn("WARNING: very small terminal grid detected with padding " ++
            "set. Is your padding reasonable?", .{});
    }
    if (self.grid_size.rows < 2 and (self.padding.top > 0 or self.padding.bottom > 0)) {
        log.warn("WARNING: very small terminal grid detected with padding " ++
            "set. Is your padding reasonable?", .{});
    }

    // Mail the IO thread
    _ = self.io_thread.mailbox.push(.{
        .resize = .{
            .grid_size = self.grid_size,
            .screen_size = self.screen_size,
            .padding = self.padding,
        },
    }, .{ .forever = {} });
    try self.io_thread.wakeup.notify();
}

/// 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.
pub fn preeditCallback(self: *Surface, preedit: ?u21) !void {
    self.renderer_state.mutex.lock();
    defer self.renderer_state.mutex.unlock();
    self.renderer_state.preedit = if (preedit) |v| .{
        .codepoint = v,
    } else null;
    try self.queueRender();
}

/// Called for any key events. This handles keybindings, encoding and
/// sending to the termianl, etc. The return value is true if the key
/// was handled and false if it was not.
pub fn keyCallback(
    self: *Surface,
    event: input.KeyEvent,
) !bool {
    // log.debug("keyCallback event={}", .{event});

    // Before encoding, we see if we have any keybindings for this
    // key. Those always intercept before any encoding tasks.
    binding: {
        const binding_action: input.Binding.Action, const consumed = action: {
            const binding_mods = event.mods.binding();
            var trigger: input.Binding.Trigger = .{
                .mods = binding_mods,
                .key = event.key,
            };

            const set = self.config.keybind.set;
            if (set.get(trigger)) |v| break :action .{
                v,
                set.getConsumed(trigger),
            };

            trigger.key = event.physical_key;
            trigger.physical = true;
            if (set.get(trigger)) |v| break :action .{
                v,
                set.getConsumed(trigger),
            };

            break :binding;
        };

        // We only execute the binding on press/repeat but we still consume
        // the key on release so that we don't send any release events.
        log.debug("key event binding consumed={} action={}", .{ consumed, binding_action });
        const performed = if (event.action == .press or event.action == .repeat)
            try self.performBindingAction(binding_action)
        else
            false;

        // 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 and performed) return true;
    }

    // If this input event has text, then we hide the mouse if configured.
    if (self.config.mouse_hide_while_typing and
        !self.mouse.hidden and
        event.utf8.len > 0)
    {
        self.hideMouse();
    }

    // No binding, so we have to perform an encoding task. This
    // may still result in no encoding. Under different modes and
    // inputs there are many keybindings that result in no encoding
    // whatsoever.
    const enc: input.KeyEncoder = enc: {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();
        const t = &self.io.terminal;
        break :enc .{
            .event = event,
            .alt_esc_prefix = t.modes.get(.alt_esc_prefix),
            .cursor_key_application = t.modes.get(.cursor_keys),
            .keypad_key_application = t.modes.get(.keypad_keys),
            .modify_other_keys_state_2 = t.flags.modify_other_keys_2,
            .kitty_flags = t.screen.kitty_keyboard.current(),
        };
    };

    var data: termio.Message.WriteReq.Small.Array = undefined;
    const seq = try enc.encode(&data);
    if (seq.len == 0) return false;

    _ = self.io_thread.mailbox.push(.{
        .write_small = .{
            .data = data,
            .len = @intCast(seq.len),
        },
    }, .{ .forever = {} });
    try self.io_thread.wakeup.notify();

    // If we have printable text to emit then we always want to clear the
    // selection and scroll to the bottom.
    if (event.utf8.len > 0) {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();
        self.setSelection(null);
        try self.io.terminal.scrollViewport(.{ .bottom = {} });
        try self.queueRender();
    }

    return true;
}

/// 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 {
    try self.completeClipboardPaste(text);
}

pub fn focusCallback(self: *Surface, focused: bool) !void {
    // Notify our render thread of the new state
    _ = self.renderer_thread.mailbox.push(.{
        .focus = focused,
    }, .{ .forever = {} });

    // Notify our app if we gained focus.
    if (focused) self.app.focusSurface(self);

    // Schedule render which also drains our mailbox
    try self.queueRender();

    // Notify the app about focus in/out if it is requesting it
    {
        self.renderer_state.mutex.lock();
        const focus_event = self.io.terminal.modes.get(.focus_event);
        self.renderer_state.mutex.unlock();

        if (focus_event) {
            const seq = if (focused) "\x1b[I" else "\x1b[O";
            _ = self.io_thread.mailbox.push(.{
                .write_stable = seq,
            }, .{ .forever = {} });

            try self.io_thread.wakeup.notify();
        }
    }
}

pub fn refreshCallback(self: *Surface) !void {
    // The point of this callback is to schedule a render, so do that.
    try self.queueRender();
}

pub fn scrollCallback(
    self: *Surface,
    xoff: f64,
    yoff: f64,
    scroll_mods: input.ScrollMods,
) !void {
    const tracy = trace(@src());
    defer tracy.end();

    // log.info("SCROLL: xoff={} yoff={} mods={}", .{ xoff, yoff, scroll_mods });

    // Always show the mouse again if it is hidden
    if (self.mouse.hidden) self.showMouse();

    const ScrollAmount = struct {
        // Positive is up, right
        sign: isize = 1,
        delta_unsigned: usize = 0,
        delta: isize = 0,
    };

    const y: ScrollAmount = if (yoff == 0) .{} else y: {
        // Non-precision scrolling is easy to calculate.
        if (!scroll_mods.precision) {
            const y_sign: isize = if (yoff > 0) -1 else 1;
            const y_delta_unsigned: usize = @max(@divFloor(self.grid_size.rows, 15), 1);
            const y_delta: isize = y_sign * @as(isize, @intCast(y_delta_unsigned));
            break :y .{ .sign = y_sign, .delta_unsigned = y_delta_unsigned, .delta = y_delta };
        }

        // Precision scrolling is more complicated. We need to maintain state
        // to build up a pending scroll amount if we're only scrolling by a
        // tiny amount so that we can scroll by a full row when we have enough.

        // Add our previously saved pending amount to the offset to get the
        // new offset value.
        //
        // NOTE: we currently multiply by -1 because macOS sends the opposite
        // of what we expect. This is jank we should audit our sign usage and
        // carefully document what we expect so this can work cross platform.
        // Right now this isn't important because macOS is the only high-precision
        // scroller.
        const poff = self.mouse.pending_scroll_y + (yoff * -1);

        // If the new offset is less than a single unit of scroll, we save
        // the new pending value and do not scroll yet.
        const cell_size: f64 = @floatFromInt(self.cell_size.height);
        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;
        self.mouse.pending_scroll_y = poff - (amount * cell_size);

        break :y .{
            .sign = if (yoff > 0) 1 else -1,
            .delta_unsigned = @intFromFloat(@abs(amount)),
            .delta = @intFromFloat(amount),
        };
    };

    // For detailed comments see the y calculation above.
    const x: ScrollAmount = if (xoff == 0) .{} else x: {
        if (!scroll_mods.precision) {
            const x_sign: isize = if (xoff < 0) -1 else 1;
            const x_delta_unsigned: usize = 1;
            const x_delta: isize = x_sign * @as(isize, @intCast(x_delta_unsigned));
            break :x .{ .sign = x_sign, .delta_unsigned = x_delta_unsigned, .delta = x_delta };
        }

        const poff = self.mouse.pending_scroll_x + (xoff * -1);
        const cell_size: f64 = @floatFromInt(self.cell_size.width);
        if (@abs(poff) < cell_size) {
            self.mouse.pending_scroll_x = poff;
            break :x .{};
        }

        const amount = poff / cell_size;
        self.mouse.pending_scroll_x = poff - (amount * cell_size);

        break :x .{
            .delta_unsigned = @intFromFloat(@abs(amount)),
            .delta = @intFromFloat(amount),
        };
    };

    log.info("scroll: delta_y={} delta_x={}", .{ y.delta, x.delta });

    {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();

        // If we have an active mouse reporting mode, clear the selection.
        // The selection can occur if the user uses the shift mod key to
        // override mouse grabbing from the window.
        if (self.io.terminal.flags.mouse_event != .none) {
            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.active_screen == .alternate and
            self.io.terminal.flags.mouse_event == .none and
            self.io.terminal.modes.get(.mouse_alternate_scroll))
        {
            if (y.delta_unsigned > 0) {
                const seq = if (y.delta < 0) "\x1bOA" else "\x1bOB";
                for (0..y.delta_unsigned) |_| {
                    _ = self.io_thread.mailbox.push(.{
                        .write_stable = seq,
                    }, .{ .forever = {} });
                }
            }

            // After sending all our messages we have to notify our IO thread
            try self.io_thread.wakeup.notify();
            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.io.terminal.flags.mouse_event != .none) {
            if (y.delta != 0) {
                const pos = try self.rt_surface.getCursorPos();
                try self.mouseReport(if (y.delta < 0) .four else .five, .press, self.mouse.mods, pos);
            }

            if (x.delta != 0) {
                const pos = try self.rt_surface.getCursorPos();
                try self.mouseReport(if (x.delta > 0) .six else .seven, .press, self.mouse.mods, pos);
            }

            // If mouse reporting is on, we do not want to scroll the
            // viewport.
            return;
        }

        // Modify our viewport, this requires a lock since it affects rendering
        try self.io.terminal.scrollViewport(.{ .delta = y.delta });
    }

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

    self.setFontSize(size);

    // Update our padding which is dependent on DPI.
    self.padding = padding: {
        const padding_x: u32 = padding_x: {
            const padding_x: f32 = @floatFromInt(self.config.window_padding_x);
            break :padding_x @intFromFloat(@floor(padding_x * x_dpi / 72));
        };
        const padding_y: u32 = padding_y: {
            const padding_y: f32 = @floatFromInt(self.config.window_padding_y);
            break :padding_y @intFromFloat(@floor(padding_y * y_dpi / 72));
        };

        break :padding .{
            .top = padding_y,
            .bottom = padding_y,
            .right = padding_x,
            .left = padding_x,
        };
    };

    // Force a resize event because the change in padding will affect
    // pixel-level changes to the renderer and viewport.
    try self.resize(self.screen_size);
}

/// The type of action to report for a mouse event.
const MouseReportAction = enum { press, release, motion };

fn mouseReport(
    self: *Surface,
    button: ?input.MouseButton,
    action: MouseReportAction,
    mods: input.Mods,
    pos: apprt.CursorPos,
) !void {
    // Depending on the event, we may do nothing at all.
    switch (self.io.terminal.flags.mouse_event) {
        .none => return,

        // X10 only reports clicks with mouse button 1, 2, 3. We verify
        // the button later.
        .x10 => if (action != .press or
            button == null or
            !(button.? == .left or
            button.? == .right or
            button.? == .middle)) return,

        // Doesn't report motion
        .normal => if (action == .motion) return,

        // Button must be pressed
        .button => if (button == null) return,

        // Everything
        .any => {},
    }

    // Handle scenarios where the mouse position is outside the viewport.
    // We always report release events no matter where they happen.
    if (action != .release) {
        const pos_out_viewport = pos_out_viewport: {
            const max_x: f32 = @floatFromInt(self.screen_size.width);
            const max_y: f32 = @floatFromInt(self.screen_size.height);
            break :pos_out_viewport pos.x < 0 or pos.y < 0 or
                pos.x > max_x or pos.y > max_y;
        };
        if (pos_out_viewport) outside_viewport: {
            // If we don't have a motion-tracking event mode, do nothing.
            if (!self.io.terminal.flags.mouse_event.motion()) return;

            // If any button is pressed, we still do the report. Otherwise,
            // we do not do the report.
            for (self.mouse.click_state) |state| {
                if (state != .release) break :outside_viewport;
            }

            return;
        }
    }

    // This format reports X/Y
    const viewport_point = self.posToViewport(pos.x, pos.y);

    // Record our new point. We only want to send a mouse event if the
    // cell changed, unless we're tracking raw pixels.
    if (action == .motion and self.io.terminal.flags.mouse_format != .sgr_pixels) {
        if (self.mouse.event_point) |last_point| {
            if (last_point.eql(viewport_point)) return;
        }
    }
    self.mouse.event_point = viewport_point;

    // Get the code we'll actually write
    const button_code: u8 = code: {
        var acc: u8 = 0;

        // Determine our initial button value
        if (button == null) {
            // Null button means motion without a button pressed
            acc = 3;
        } else if (action == .release and
            self.io.terminal.flags.mouse_format != .sgr and
            self.io.terminal.flags.mouse_format != .sgr_pixels)
        {
            // Release is 3. It is NOT 3 in SGR mode because SGR can tell
            // the application what button was released.
            acc = 3;
        } else {
            acc = switch (button.?) {
                .left => 0,
                .middle => 1,
                .right => 2,
                .four => 64,
                .five => 65,
                else => return, // unsupported
            };
        }

        // X10 doesn't have modifiers
        if (self.io.terminal.flags.mouse_event != .x10) {
            if (mods.shift) acc += 4;
            if (mods.alt) acc += 8;
            if (mods.ctrl) acc += 16;
        }

        // Motion adds another bit
        if (action == .motion) acc += 32;

        break :code acc;
    };

    switch (self.io.terminal.flags.mouse_format) {
        .x10 => {
            if (viewport_point.x > 222 or viewport_point.y > 222) {
                log.info("X10 mouse format can only encode X/Y up to 223", .{});
                return;
            }

            // + 1 below is because our x/y is 0-indexed and the protocol wants 1
            var data: termio.Message.WriteReq.Small.Array = undefined;
            assert(data.len >= 6);
            data[0] = '\x1b';
            data[1] = '[';
            data[2] = 'M';
            data[3] = 32 + button_code;
            data[4] = 32 + @as(u8, @intCast(viewport_point.x)) + 1;
            data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1;

            // Ask our IO thread to write the data
            _ = self.io_thread.mailbox.push(.{
                .write_small = .{
                    .data = data,
                    .len = 6,
                },
            }, .{ .forever = {} });
        },

        .utf8 => {
            // Maximum of 12 because at most we have 2 fully UTF-8 encoded chars
            var data: termio.Message.WriteReq.Small.Array = undefined;
            assert(data.len >= 12);
            data[0] = '\x1b';
            data[1] = '[';
            data[2] = 'M';

            // The button code will always fit in a single u8
            data[3] = 32 + button_code;

            // UTF-8 encode the x/y
            var i: usize = 4;
            i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.x + 1), data[i..]);
            i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]);

            // Ask our IO thread to write the data
            _ = self.io_thread.mailbox.push(.{
                .write_small = .{
                    .data = data,
                    .len = @intCast(i),
                },
            }, .{ .forever = {} });
        },

        .sgr => {
            // Final character to send in the CSI
            const final: u8 = if (action == .release) 'm' else 'M';

            // Response always is at least 4 chars, so this leaves the
            // remainder for numbers which are very large...
            var data: termio.Message.WriteReq.Small.Array = undefined;
            const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
                button_code,
                viewport_point.x + 1,
                viewport_point.y + 1,
                final,
            });

            // Ask our IO thread to write the data
            _ = self.io_thread.mailbox.push(.{
                .write_small = .{
                    .data = data,
                    .len = @intCast(resp.len),
                },
            }, .{ .forever = {} });
        },

        .urxvt => {
            // Response always is at least 4 chars, so this leaves the
            // remainder for numbers which are very large...
            var data: termio.Message.WriteReq.Small.Array = undefined;
            const resp = try std.fmt.bufPrint(&data, "\x1B[{d};{d};{d}M", .{
                32 + button_code,
                viewport_point.x + 1,
                viewport_point.y + 1,
            });

            // Ask our IO thread to write the data
            _ = self.io_thread.mailbox.push(.{
                .write_small = .{
                    .data = data,
                    .len = @intCast(resp.len),
                },
            }, .{ .forever = {} });
        },

        .sgr_pixels => {
            // Final character to send in the CSI
            const final: u8 = if (action == .release) 'm' else 'M';

            // Response always is at least 4 chars, so this leaves the
            // remainder for numbers which are very large...
            var data: termio.Message.WriteReq.Small.Array = undefined;
            const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
                button_code,
                @as(i32, @intFromFloat(@round(pos.x))),
                @as(i32, @intFromFloat(@round(pos.y))),
                final,
            });

            // Ask our IO thread to write the data
            _ = self.io_thread.mailbox.push(.{
                .write_small = .{
                    .data = data,
                    .len = @intCast(resp.len),
                },
            }, .{ .forever = {} });
        },
    }

    // After sending all our messages we have to notify our IO thread
    try self.io_thread.wakeup.notify();
}

pub fn mouseButtonCallback(
    self: *Surface,
    action: input.MouseButtonState,
    button: input.MouseButton,
    mods: input.Mods,
) !void {
    // log.debug("mouse action={} button={} mods={}", .{ action, button, mods });

    const tracy = trace(@src());
    defer tracy.end();

    // Always record our latest mouse state
    self.mouse.click_state[@intCast(@intFromEnum(button))] = action;
    self.mouse.mods = @bitCast(mods);

    // Always show the mouse again if it is hidden
    if (self.mouse.hidden) self.showMouse();

    // 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) {
        if (mods.shift and self.mouse.left_click_count > 0) {
            // Checking for selection requires the renderer state mutex which
            // sucks but this should be pretty rare of an event so it won't
            // cause a ton of contention.
            const selection = selection: {
                self.renderer_state.mutex.lock();
                defer self.renderer_state.mutex.unlock();
                break :selection self.io.terminal.screen.selection != null;
            };

            if (selection) {
                const pos = try self.rt_surface.getCursorPos();
                try self.cursorPosCallback(pos);
                return;
            }
        }
    }

    // Report mouse events if enabled
    {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();
        if (self.io.terminal.flags.mouse_event != .none) report: {
            // Shift overrides mouse "grabbing" in the window, taken from Kitty.
            if (mods.shift) 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").
            self.setSelection(null);

            const pos = try self.rt_surface.getCursorPos();

            const report_action: MouseReportAction = switch (action) {
                .press => .press,
                .release => .release,
            };

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

    // For left button clicks we always record some information for
    // selection/highlighting purposes.
    if (button == .left and action == .press) {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();

        const pos = try self.rt_surface.getCursorPos();

        // If we move our cursor too much between clicks then we reset
        // the multi-click state.
        if (self.mouse.left_click_count > 0) {
            const max_distance: f64 = @floatFromInt(self.cell_size.width);
            const distance = @sqrt(
                std.math.pow(f64, pos.x - self.mouse.left_click_xpos, 2) +
                    std.math.pow(f64, pos.y - self.mouse.left_click_ypos, 2),
            );

            if (distance > max_distance) self.mouse.left_click_count = 0;
        }

        // Store it
        const point = self.posToViewport(pos.x, pos.y);
        self.mouse.left_click_point = point.toScreen(&self.io.terminal.screen);
        self.mouse.left_click_xpos = pos.x;
        self.mouse.left_click_ypos = pos.y;

        // Setup our click counter and timer
        if (std.time.Instant.now()) |now| {
            // If we have mouse clicks, then we check if the time elapsed
            // is less than and our interval and if so, increase the count.
            if (self.mouse.left_click_count > 0) {
                const since = now.since(self.mouse.left_click_time);
                if (since > self.config.mouse_interval) {
                    self.mouse.left_click_count = 0;
                }
            }

            self.mouse.left_click_time = now;
            self.mouse.left_click_count += 1;

            // We only support up to triple-clicks.
            if (self.mouse.left_click_count > 3) self.mouse.left_click_count = 1;
        } else |err| {
            self.mouse.left_click_count = 1;
            log.err("error reading time, mouse multi-click won't work err={}", .{err});
        }

        switch (self.mouse.left_click_count) {
            // First mouse click, clear selection
            1 => if (self.io.terminal.screen.selection != null) {
                self.setSelection(null);
                try self.queueRender();
            },

            // Double click, select the word under our mouse
            2 => {
                const sel_ = self.io.terminal.screen.selectWord(self.mouse.left_click_point);
                if (sel_) |sel| {
                    self.setSelection(sel);
                    try self.queueRender();
                }
            },

            // Triple click, select the line under our mouse
            3 => {
                const sel_ = self.io.terminal.screen.selectLine(self.mouse.left_click_point);
                if (sel_) |sel| {
                    self.setSelection(sel);
                    try self.queueRender();
                }
            },

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

    // Middle-click pastes from our selection clipboard
    if (button == .middle and action == .press) {
        if (self.config.copy_on_select != .false) {
            const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) {
                .true => .selection,
                .clipboard => .standard,
                .false => unreachable,
            };

            try self.startClipboardRequest(clipboard, .{ .paste = {} });
        }
    }
}

pub fn cursorPosCallback(
    self: *Surface,
    pos: apprt.CursorPos,
) !void {
    const tracy = trace(@src());
    defer tracy.end();

    // Always show the mouse again if it is hidden
    if (self.mouse.hidden) self.showMouse();

    // We are reading/writing state for the remainder
    self.renderer_state.mutex.lock();
    defer self.renderer_state.mutex.unlock();

    // Do a mouse report
    if (self.io.terminal.flags.mouse_event != .none) report: {
        // Shift overrides mouse "grabbing" in the window, taken from Kitty.
        if (self.mouse.mods.shift) 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;

        try self.mouseReport(button, .motion, self.mouse.mods, pos);

        // If we're doing mouse motion tracking, we do not support text
        // selection.
        return;
    }

    // If the cursor isn't clicked currently, it doesn't matter
    if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] != .press) return;

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

    // If our y is negative, we're above the window. In this case, we scroll
    // up. The amount we scroll up is dependent on how negative we are.
    // Note: one day, we can change this from distance to time based if we want.
    //log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size });
    const max_y: f32 = @floatFromInt(self.screen_size.height);
    if (pos.y < 0 or pos.y > max_y) {
        const delta: isize = if (pos.y < 0) -1 else 1;
        try self.io.terminal.scrollViewport(.{ .delta = delta });

        // TODO: We want a timer or something to repeat while we're still
        // at this cursor position. Right now, the user has to jiggle their
        // mouse in order to scroll.
    }

    // Convert to points
    const viewport_point = self.posToViewport(pos.x, pos.y);
    const screen_point = viewport_point.toScreen(&self.io.terminal.screen);

    // Handle dragging depending on click count
    switch (self.mouse.left_click_count) {
        1 => self.dragLeftClickSingle(screen_point, pos.x),
        2 => self.dragLeftClickDouble(screen_point),
        3 => self.dragLeftClickTriple(screen_point),
        else => unreachable,
    }
}

/// Double-click dragging moves the selection one "word" at a time.
fn dragLeftClickDouble(
    self: *Surface,
    screen_point: terminal.point.ScreenPoint,
) void {
    // Get the word under our current point. If there isn't a word, do nothing.
    const word = self.io.terminal.screen.selectWord(screen_point) orelse return;

    // Get our selection to grow it. If we don't have a selection, start it now.
    // We may not have a selection if we started our dbl-click in an area
    // that had no data, then we dragged our mouse into an area with data.
    var sel = self.io.terminal.screen.selectWord(self.mouse.left_click_point) orelse {
        self.setSelection(word);
        return;
    };

    // Grow our selection
    if (screen_point.before(self.mouse.left_click_point)) {
        sel.start = word.start;
    } else {
        sel.end = word.end;
    }
    self.setSelection(sel);
}

/// Triple-click dragging moves the selection one "line" at a time.
fn dragLeftClickTriple(
    self: *Surface,
    screen_point: terminal.point.ScreenPoint,
) void {
    // Get the word under our current point. If there isn't a word, do nothing.
    const word = self.io.terminal.screen.selectLine(screen_point) orelse return;

    // Get our selection to grow it. If we don't have a selection, start it now.
    // We may not have a selection if we started our dbl-click in an area
    // that had no data, then we dragged our mouse into an area with data.
    var sel = self.io.terminal.screen.selectLine(self.mouse.left_click_point) orelse {
        self.setSelection(word);
        return;
    };

    // Grow our selection
    if (screen_point.before(self.mouse.left_click_point)) {
        sel.start = word.start;
    } else {
        sel.end = word.end;
    }
    self.setSelection(sel);
}

fn dragLeftClickSingle(
    self: *Surface,
    screen_point: terminal.point.ScreenPoint,
    xpos: f64,
) void {
    // NOTE(mitchellh): This logic super sucks. There has to be an easier way
    // to calculate this, but this is good for a v1. Selection isn't THAT
    // common so its not like this performance heavy code is running that
    // often.
    // TODO: unit test this, this logic sucks

    // If we were selecting, and we switched directions, then we restart
    // calculations because it forces us to reconsider if the first cell is
    // selected.
    if (self.io.terminal.screen.selection) |sel| {
        const reset: bool = if (sel.end.before(sel.start))
            sel.start.before(screen_point)
        else
            screen_point.before(sel.start);

        if (reset) self.setSelection(null);
    }

    // Our logic for determining if the starting cell is selected:
    //
    //   - The "xboundary" is 60% the width of a cell from the left. We choose
    //     60% somewhat arbitrarily based on feeling.
    //   - If we started our click left of xboundary, backwards selections
    //     can NEVER select the current char.
    //   - If we started our click right of xboundary, backwards selections
    //     ALWAYS selected the current char, but we must move the cursor
    //     left of the xboundary.
    //   - Inverted logic for forwards selections.
    //

    // the boundary point at which we consider selection or non-selection
    const cell_xboundary = @as(f32, @floatFromInt(self.cell_size.width)) * 0.6;

    // first xpos of the clicked cell
    const cell_xstart = @as(
        f32,
        @floatFromInt(self.mouse.left_click_point.x),
    ) * @as(f32, @floatFromInt(self.cell_size.width));
    const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart;

    // If this is the same cell, then we only start the selection if weve
    // moved past the boundary point the opposite direction from where we
    // started.
    if (std.meta.eql(screen_point, self.mouse.left_click_point)) {
        const cell_xpos = xpos - cell_xstart;
        const selected: bool = if (cell_start_xpos < cell_xboundary)
            cell_xpos >= cell_xboundary
        else
            cell_xpos < cell_xboundary;

        self.setSelection(if (selected) .{
            .start = screen_point,
            .end = screen_point,
        } else null);

        return;
    }

    // If this is a different cell and we haven't started selection,
    // we determine the starting cell first.
    if (self.io.terminal.screen.selection == null) {
        //   - If we're moving to a point before the start, then we select
        //     the starting cell if we started after the boundary, else
        //     we start selection of the prior cell.
        //   - Inverse logic for a point after the start.
        const click_point = self.mouse.left_click_point;
        const start: terminal.point.ScreenPoint = if (screen_point.before(click_point)) start: {
            if (cell_start_xpos >= cell_xboundary) {
                break :start click_point;
            } else {
                break :start if (click_point.x > 0) terminal.point.ScreenPoint{
                    .y = click_point.y,
                    .x = click_point.x - 1,
                } else terminal.point.ScreenPoint{
                    .x = self.io.terminal.screen.cols - 1,
                    .y = click_point.y -| 1,
                };
            }
        } else start: {
            if (cell_start_xpos < cell_xboundary) {
                break :start click_point;
            } else {
                break :start if (click_point.x < self.io.terminal.screen.cols - 1) terminal.point.ScreenPoint{
                    .y = click_point.y,
                    .x = click_point.x + 1,
                } else terminal.point.ScreenPoint{
                    .y = click_point.y + 1,
                    .x = 0,
                };
            }
        };

        self.setSelection(.{ .start = start, .end = screen_point });
        return;
    }

    // TODO: detect if selection point is passed the point where we've
    // actually written data before and disallow it.

    // We moved! Set the selection end point. The start point should be
    // set earlier.
    assert(self.io.terminal.screen.selection != null);
    var sel = self.io.terminal.screen.selection.?;
    sel.end = screen_point;
    self.setSelection(sel);
}

fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Viewport {
    // xpos/ypos need to be adjusted for window padding
    // (i.e. "window-padding-*" settings. NOTE we don't adjust for
    // "window-padding-balance" because we don't have access to the balance
    // amount from the renderer. This is a bug but realistically balanced
    // padding is so small it doesn't affect selection. This may not be true
    // at large font sizes...
    const xpos_adjusted: f64 = xpos - @as(f64, @floatFromInt(self.padding.left));
    const ypos_adjusted: f64 = ypos - @as(f64, @floatFromInt(self.padding.top));

    // xpos and ypos can be negative if while dragging, the user moves the
    // mouse off the surface. Likewise, they can be larger than our surface
    // width if the user drags out of the surface positively.
    return .{
        .x = if (xpos_adjusted < 0) 0 else x: {
            // Our cell is the mouse divided by cell width
            const cell_width: f64 = @floatFromInt(self.cell_size.width);
            const x: usize = @intFromFloat(xpos_adjusted / cell_width);

            // Can be off the screen if the user drags it out, so max
            // it out on our available columns
            break :x @min(x, self.grid_size.columns - 1);
        },

        .y = if (ypos_adjusted < 0) 0 else y: {
            const cell_height: f64 = @floatFromInt(self.cell_size.height);
            const y: usize = @intFromFloat(ypos_adjusted / cell_height);
            break :y @min(y, self.grid_size.rows - 1);
        },
    };
}

/// Scroll to the bottom of the viewport.
///
/// Precondition: the render_state mutex must be held.
fn scrollToBottom(self: *Surface) !void {
    try self.io.terminal.scrollViewport(.{ .bottom = {} });
    try self.queueRender();
}

fn hideMouse(self: *Surface) void {
    if (self.mouse.hidden) return;
    self.mouse.hidden = true;
    self.rt_surface.setMouseVisibility(false);
}

fn showMouse(self: *Surface) void {
    if (!self.mouse.hidden) return;
    self.mouse.hidden = false;
    self.rt_surface.setMouseVisibility(true);
}

/// 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 {
    switch (action) {
        .unbind => unreachable,
        .ignore => {},

        .reload_config => try self.app.reloadConfig(self.rt_app),

        .csi => |data| {
            // We need to send the CSI 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 = try std.fmt.bufPrint(&buf, "\x1b[{s}", .{data});
            _ = self.io_thread.mailbox.push(try termio.Message.writeReq(
                self.alloc,
                full_data,
            ), .{ .forever = {} });
            try self.io_thread.wakeup.notify();

            // CSI triggers a scroll.
            {
                self.renderer_state.mutex.lock();
                defer self.renderer_state.mutex.unlock();
                self.scrollToBottom() catch |err| {
                    log.warn("error scrolling to bottom err={}", .{err});
                };
            }
        },

        .cursor_key => |ck| {
            // We send a different sequence depending on if we're
            // in cursor keys mode. We're in "normal" mode if cursor
            // keys mode is NOT set.
            const normal = normal: {
                self.renderer_state.mutex.lock();
                defer self.renderer_state.mutex.unlock();

                // With the lock held, we must scroll to the bottom.
                // We always scroll to the bottom for these inputs.
                self.scrollToBottom() catch |err| {
                    log.warn("error scrolling to bottom err={}", .{err});
                };

                break :normal !self.io.terminal.modes.get(.cursor_keys);
            };

            if (normal) {
                _ = self.io_thread.mailbox.push(.{
                    .write_stable = ck.normal,
                }, .{ .forever = {} });
            } else {
                _ = self.io_thread.mailbox.push(.{
                    .write_stable = ck.application,
                }, .{ .forever = {} });
            }

            try self.io_thread.wakeup.notify();
        },

        .copy_to_clipboard => {
            // We can read from the renderer state without holding
            // the lock because only we will write to this field.
            if (self.io.terminal.screen.selection) |sel| {
                var buf = self.io.terminal.screen.selectionString(
                    self.alloc,
                    sel,
                    self.config.clipboard_trim_trailing_spaces,
                ) catch |err| {
                    log.err("error reading selection string err={}", .{err});
                    return true;
                };
                defer self.alloc.free(buf);

                self.rt_surface.setClipboardString(buf, .standard) catch |err| {
                    log.err("error setting clipboard string err={}", .{err});
                    return true;
                };
            }
        },

        .paste_from_clipboard => try self.startClipboardRequest(
            .standard,
            .{ .paste = {} },
        ),

        .increase_font_size => |delta| {
            log.debug("increase font size={}", .{delta});

            var size = self.font_size;
            size.points +|= delta;
            self.setFontSize(size);
        },

        .decrease_font_size => |delta| {
            log.debug("decrease font size={}", .{delta});

            var size = self.font_size;
            size.points = @max(1, size.points -| delta);
            self.setFontSize(size);
        },

        .reset_font_size => {
            log.debug("reset font size", .{});

            var size = self.font_size;
            size.points = self.config.original_font_size;
            self.setFontSize(size);
        },

        .clear_screen => {
            _ = self.io_thread.mailbox.push(.{
                .clear_screen = .{ .history = true },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .scroll_to_top => {
            _ = self.io_thread.mailbox.push(.{
                .scroll_viewport = .{ .top = {} },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .scroll_to_bottom => {
            _ = self.io_thread.mailbox.push(.{
                .scroll_viewport = .{ .bottom = {} },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .scroll_page_up => {
            const rows: isize = @intCast(self.grid_size.rows);
            _ = self.io_thread.mailbox.push(.{
                .scroll_viewport = .{ .delta = -1 * rows },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .scroll_page_down => {
            const rows: isize = @intCast(self.grid_size.rows);
            _ = self.io_thread.mailbox.push(.{
                .scroll_viewport = .{ .delta = rows },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .scroll_page_fractional => |fraction| {
            const rows: f32 = @floatFromInt(self.grid_size.rows);
            const delta: isize = @intFromFloat(@floor(fraction * rows));
            _ = self.io_thread.mailbox.push(.{
                .scroll_viewport = .{ .delta = delta },
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .jump_to_prompt => |delta| {
            _ = self.io_thread.mailbox.push(.{
                .jump_to_prompt = @intCast(delta),
            }, .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .write_scrollback_file => write_scrollback_file: {
            // Create a temporary directory to store our scrollback.
            var tmp_dir = try internal_os.TempDir.init();
            errdefer tmp_dir.deinit();

            // Open our scrollback file
            var file = try tmp_dir.dir.createFile("scrollback", .{});
            defer file.close();

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

                // We do not support this for alternate screens
                // because they don't have scrollback anyways.
                if (self.io.terminal.active_screen == .alternate) {
                    tmp_dir.deinit();
                    break :write_scrollback_file;
                }

                const history_max = terminal.Screen.RowIndexTag.history.maxLen(
                    &self.io.terminal.screen,
                );

                try self.io.terminal.screen.dumpString(file.writer(), .{
                    .start = .{ .history = 0 },
                    .end = .{ .history = history_max -| 1 },
                    .unwrap = true,
                });
            }

            // Get the final path
            var path_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
            const path = try tmp_dir.dir.realpath("scrollback", &path_buf);

            _ = self.io_thread.mailbox.push(try termio.Message.writeReq(
                self.alloc,
                path,
            ), .{ .forever = {} });
            try self.io_thread.wakeup.notify();
        },

        .new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }),

        .new_tab => {
            if (@hasDecl(apprt.Surface, "newTab")) {
                try self.rt_surface.newTab();
            } else log.warn("runtime doesn't implement newTab", .{});
        },

        .previous_tab => {
            if (@hasDecl(apprt.Surface, "hasTabs")) {
                if (!self.rt_surface.hasTabs()) {
                    log.debug("surface has no tabs, ignoring previous_tab binding", .{});
                    return false;
                }
            }

            if (@hasDecl(apprt.Surface, "gotoPreviousTab")) {
                self.rt_surface.gotoPreviousTab();
            } else log.warn("runtime doesn't implement gotoPreviousTab", .{});
        },

        .next_tab => {
            if (@hasDecl(apprt.Surface, "hasTabs")) {
                if (!self.rt_surface.hasTabs()) {
                    log.debug("surface has no tabs, ignoring next_tab binding", .{});
                    return false;
                }
            }

            if (@hasDecl(apprt.Surface, "gotoNextTab")) {
                self.rt_surface.gotoNextTab();
            } else log.warn("runtime doesn't implement gotoNextTab", .{});
        },

        .goto_tab => |n| {
            if (@hasDecl(apprt.Surface, "gotoTab")) {
                self.rt_surface.gotoTab(n);
            } else log.warn("runtime doesn't implement gotoTab", .{});
        },

        .new_split => |direction| {
            if (@hasDecl(apprt.Surface, "newSplit")) {
                try self.rt_surface.newSplit(direction);
            } else log.warn("runtime doesn't implement newSplit", .{});
        },

        .goto_split => |direction| {
            if (@hasDecl(apprt.Surface, "gotoSplit")) {
                self.rt_surface.gotoSplit(direction);
            } else log.warn("runtime doesn't implement gotoSplit", .{});
        },

        .toggle_split_zoom => {
            if (@hasDecl(apprt.Surface, "toggleSplitZoom")) {
                self.rt_surface.toggleSplitZoom();
            } else log.warn("runtime doesn't implement toggleSplitZoom", .{});
        },

        .toggle_fullscreen => {
            if (@hasDecl(apprt.Surface, "toggleFullscreen")) {
                self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen);
            } else log.warn("runtime doesn't implement toggleFullscreen", .{});
        },

        .close_surface => self.close(),

        .close_window => try self.app.closeSurface(self),

        .quit => try self.app.setQuit(),
    }

    return true;
}

/// Call this to complete a clipboard request sent to apprt. This should
/// only be called once for each request. The data is immediately copied so
/// it is safe to free the data after this call.
pub fn completeClipboardRequest(
    self: *Surface,
    req: apprt.ClipboardRequest,
    data: []const u8,
) !void {
    switch (req) {
        .paste => try self.completeClipboardPaste(data),
        .osc_52 => |kind| try self.completeClipboardReadOSC52(data, kind),
    }
}

/// This starts a clipboard request, with some basic validation. For example,
/// an OSC 52 request is not actually requested if OSC 52 is disabled.
fn startClipboardRequest(
    self: *Surface,
    loc: apprt.Clipboard,
    req: apprt.ClipboardRequest,
) !void {
    switch (req) {
        .paste => {}, // always allowed
        .osc_52 => if (!self.config.clipboard_read) {
            log.info(
                "application attempted to read clipboard, but 'clipboard-read' setting is off",
                .{},
            );
            return;
        },
    }

    try self.rt_surface.clipboardRequest(loc, req);
}

fn completeClipboardPaste(self: *Surface, data: []const u8) !void {
    if (data.len == 0) return;

    const bracketed = bracketed: {
        self.renderer_state.mutex.lock();
        defer self.renderer_state.mutex.unlock();

        // With the lock held, we must scroll to the bottom.
        // We always scroll to the bottom for these inputs.
        self.scrollToBottom() catch |err| {
            log.warn("error scrolling to bottom err={}", .{err});
        };

        break :bracketed self.io.terminal.modes.get(.bracketed_paste);
    };

    if (bracketed) {
        // If we're bracketd we write the data as-is to the terminal with
        // the bracketed paste escape codes around it.
        _ = self.io_thread.mailbox.push(.{
            .write_stable = "\x1B[200~",
        }, .{ .forever = {} });
        _ = self.io_thread.mailbox.push(try termio.Message.writeReq(
            self.alloc,
            data,
        ), .{ .forever = {} });
        _ = self.io_thread.mailbox.push(.{
            .write_stable = "\x1B[201~",
        }, .{ .forever = {} });
    } else {
        // If its not bracketed the input bytes are indistinguishable from
        // keystrokes, so we must be careful. For example, we must replace
        // any newlines with '\r'.

        // We just do a heap allocation here because its easy and I don't think
        // worth the optimization of using small messages.
        var buf = try self.alloc.alloc(u8, data.len);
        defer self.alloc.free(buf);

        // This is super, super suboptimal. We can easily make use of SIMD
        // here, but maybe LLVM in release mode is smart enough to figure
        // out something clever. Either way, large non-bracketed pastes are
        // increasingly rare for modern applications.
        var len: usize = 0;
        for (data, 0..) |ch, i| {
            const dch = switch (ch) {
                '\n' => '\r',
                '\r' => if (i + 1 < data.len and data[i + 1] == '\n') continue else ch,
                else => ch,
            };

            buf[len] = dch;
            len += 1;
        }

        _ = self.io_thread.mailbox.push(try termio.Message.writeReq(
            self.alloc,
            buf[0..len],
        ), .{ .forever = {} });
    }

    try self.io_thread.wakeup.notify();
}

fn completeClipboardReadOSC52(self: *Surface, data: []const u8, kind: u8) !void {
    // Even if the clipboard data is empty we reply, since presumably
    // the client app is expecting a reply. We first allocate our buffer.
    // This must hold the base64 encoded data PLUS the OSC code surrounding it.
    const enc = std.base64.standard.Encoder;
    const size = enc.calcSize(data.len);
    var buf = try self.alloc.alloc(u8, size + 9); // const for OSC
    defer self.alloc.free(buf);

    // 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.io_thread.mailbox.push(try termio.Message.writeReq(
        self.alloc,
        buf,
    ), .{ .forever = {} });
    self.io_thread.wakeup.notify() catch {};
}

const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
const face_emoji_text_ttf = @embedFile("font/res/NotoEmoji-Regular.ttf");
