diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/apprt/gtk/class/surface.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/apprt/gtk/class/surface.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/apprt/gtk/class/surface.zig | 4049 |
1 files changed, 4049 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig new file mode 100644 index 00000000..179c779d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig @@ -0,0 +1,4049 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gdk = @import("gdk"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const apprt = @import("../../../apprt.zig"); +const build_config = @import("../../../build_config.zig"); +const configpkg = @import("../../../config.zig"); +const datastruct = @import("../../../datastruct/main.zig"); +const font = @import("../../../font/main.zig"); +const input = @import("../../../input.zig"); +const internal_os = @import("../../../os/main.zig"); +const renderer = @import("../../../renderer.zig"); +const terminal = @import("../../../terminal/main.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gresource = @import("../build/gresource.zig"); +const ext = @import("../ext.zig"); +const gsettings = @import("../gsettings.zig"); +const gtk_key = @import("../key.zig"); +const ApprtSurface = @import("../Surface.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; +const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; +const SearchOverlay = @import("search_overlay.zig").SearchOverlay; +const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; +const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; +const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; +const TitleDialog = @import("title_dialog.zig").TitleDialog; +const Window = @import("window.zig").Window; +const InspectorWindow = @import("inspector_window.zig").InspectorWindow; +const i18n = @import("../../../os/i18n.zig"); +const media = @import("../media.zig"); + +const log = std.log.scoped(.gtk_ghostty_surface); + +pub const Surface = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const Implements = [_]type{gtk.Scrollable}; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurface", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + .implements = &.{ + gobject.ext.implement(gtk.Scrollable, .{}), + }, + }); + + /// A SplitTree implementation that stores surfaces. + pub const Tree = datastruct.SplitTree(Self); + + pub const properties = struct { + /// This property is set to true when the bell is ringing. Note that + /// this property will only emit a changed signal when there is a + /// full state change. If a bell is ringing and another bell event + /// comes through, the change notification will NOT be emitted. + /// + /// If you need to know every scenario the bell is triggered, + /// listen to the `bell` signal instead. + pub const @"bell-ringing" = struct { + pub const name = "bell-ringing"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("bell_ringing"), + }, + ); + }; + + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"child-exited" = struct { + pub const name = "child-exited"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "child_exited", + ), + }, + ); + }; + + pub const @"default-size" = struct { + pub const name = "default-size"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Size, + .{ + .accessor = C.privateBoxedFieldAccessor("default_size"), + }, + ); + }; + + pub const @"error" = struct { + pub const name = "error"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "error", + ), + }, + ); + }; + + pub const @"font-size-request" = struct { + pub const name = "font-size-request"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*font.face.DesiredSize, + .{ + .accessor = C.privateBoxedFieldAccessor("font_size_request"), + }, + ); + }; + + pub const focused = struct { + pub const name = "focused"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "focused", + ), + }, + ); + }; + + pub const @"min-size" = struct { + pub const name = "min-size"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Size, + .{ + .accessor = C.privateBoxedFieldAccessor("min_size"), + }, + ); + }; + + pub const @"mouse-hidden" = struct { + pub const name = "mouse-hidden"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getMouseHidden, + .setter = setMouseHidden, + }, + ), + }, + ); + }; + + pub const @"mouse-shape" = struct { + pub const name = "mouse-shape"; + const impl = gobject.ext.defineProperty( + name, + Self, + terminal.MouseShape, + .{ + .default = .text, + .accessor = gobject.ext.typedAccessor( + Self, + terminal.MouseShape, + .{ + .getter = getMouseShape, + .setter = setMouseShape, + }, + ), + }, + ); + }; + + pub const @"mouse-hover-url" = struct { + pub const name = "mouse-hover-url"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("mouse_hover_url"), + }, + ); + }; + + pub const pwd = struct { + pub const name = "pwd"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("pwd"), + }, + ); + }; + + pub const title = struct { + pub const name = "title"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title"), + }, + ); + }; + + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; + + pub const zoom = struct { + pub const name = "zoom"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "zoom", + ), + }, + ); + }; + + pub const @"is-split" = struct { + pub const name = "is-split"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "is_split", + ), + }, + ); + }; + + pub const hadjustment = struct { + pub const name = "hadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getHAdjustmentValue, + .setter = setHAdjustmentValue, + }, + }, + ); + }; + + pub const vadjustment = struct { + pub const name = "vadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getVAdjustmentValue, + .setter = setVAdjustmentValue, + }, + }, + ); + }; + + pub const @"hscroll-policy" = struct { + pub const name = "hscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("hscroll_policy"), + }, + ); + }; + + pub const @"vscroll-policy" = struct { + pub const name = "vscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("vscroll_policy"), + }, + ); + }; + + pub const @"key-sequence" = struct { + pub const name = "key-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeySequence, + .getter_transfer = .full, + }, + ), + }, + ); + }; + + pub const @"key-table" = struct { + pub const name = "key-table"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeyTable, + .getter_transfer = .full, + }, + ), + }, + ); + }; + + pub const readonly = struct { + pub const name = "readonly"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getReadonly, + }, + ), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted whenever the bell event is received. Unlike the + /// `bell-ringing` property, this is emitted every time the event + /// is received and not just on state changes. + pub const bell = struct { + pub const name = "bell"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + /// Emitted whenever the surface would like to be closed for any + /// reason. + /// + /// The surface view does NOT handle its own close confirmation. + /// If there is a process alive then the boolean parameter will + /// specify it and the parent widget should handle this request. + /// + /// This signal lets the containing widget decide how closure works. + /// This lets this Surface widget be used as a split, tab, etc. + /// without it having to be aware of its own semantics. + pub const @"close-request" = struct { + pub const name = "close-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted whenever the clipboard has been written. + pub const @"clipboard-write" = struct { + pub const name = "clipboard-write"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{ + apprt.Clipboard, + [*:0]const u8, + }, + void, + ); + }; + + /// Emitted whenever the surface reads the clipboard. + pub const @"clipboard-read" = struct { + pub const name = "clipboard-read"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted after the surface is initialized. + pub const init = struct { + pub const name = "init"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted just prior to the context menu appearing. + pub const menu = struct { + pub const name = "menu"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when the focus wants to be brought to the top and + /// focused. + pub const @"present-request" = struct { + pub const name = "present-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when this surface requests its container to toggle its + /// fullscreen state. + pub const @"toggle-fullscreen" = struct { + pub const name = "toggle-fullscreen"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when this surface requests its container to toggle its + /// maximized state. + pub const @"toggle-maximize" = struct { + pub const name = "toggle-maximize"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The configuration that this surface is using. + config: ?*Config = null, + + /// The default size for a window that embeds this surface. + default_size: ?*Size = null, + + /// The minimum size for this surface. Embedders enforce this, + /// not the surface itself. + min_size: ?*Size = null, + + /// The requested font size. This only applies to initialization + /// and has no effect later. + font_size_request: ?*font.face.DesiredSize = null, + + /// The mouse shape to show for the surface. + mouse_shape: terminal.MouseShape = .default, + + /// Whether the mouse should be hidden or not as requested externally. + mouse_hidden: bool = false, + + /// The URL that the mouse is currently hovering over. + mouse_hover_url: ?[:0]const u8 = null, + + /// The current working directory. This has to be reported externally, + /// usually by shell integration which then talks to libghostty + /// which triggers this property. + /// + /// If this is set prior to initialization then the surface will + /// start in this pwd. If it is set after, it has no impact on the + /// core surface. + pwd: ?[:0]const u8 = null, + + /// The title of this surface, if any has been set. + title: ?[:0]const u8 = null, + + /// The manually overridden title of this surface from `promptTitle`. + title_override: ?[:0]const u8 = null, + + /// The current focus state of the terminal based on the + /// focus events. + focused: bool = true, + + /// Whether this surface is "zoomed" or not. A zoomed surface + /// shows up taking the full bounds of a split view. + zoom: bool = false, + + /// The GLAarea that renders the actual surface. This is a binding + /// to the template so it doesn't have to be unrefed manually. + gl_area: *gtk.GLArea, + + /// The labels for the left/right sides of the URL hover tooltip. + url_left: *gtk.Label, + url_right: *gtk.Label, + + /// The resize overlay + resize_overlay: *ResizeOverlay, + + /// The search overlay + search_overlay: *SearchOverlay, + + /// The key state overlay + key_state_overlay: *KeyStateOverlay, + + /// The apprt Surface. + rt_surface: ApprtSurface = undefined, + + /// The core surface backing this GTK surface. This starts out + /// null because it can't be initialized until there is an available + /// GLArea that is realized. + // + // NOTE(mitchellh): This is a limitation we should definitely remove + // at some point by modifying our OpenGL renderer for GTK to + // start in an unrealized state. There are other benefits to being + // able to initialize the surface early so we should aim for that, + // eventually. + core_surface: ?*CoreSurface = null, + + /// Cached metrics for libghostty callbacks + size: apprt.SurfaceSize, + cursor_pos: apprt.CursorPos, + + /// Various input method state. All related to key input. + in_keyevent: IMKeyEvent = .false, + im_context: *gtk.IMMulticontext, + im_composing: bool = false, + im_buf: [128]u8 = undefined, + im_len: u7 = 0, + + /// True when we have a precision scroll in progress + precision_scroll: bool = false, + + /// True when the child has exited. + child_exited: bool = false, + + // Progress bar + progress_bar_timer: ?c_uint = null, + + // True while the bell is ringing. This will be set to false (after + // true) under various scenarios, but can also manually be set to + // false by a parent widget. + bell_ringing: bool = false, + + /// True if this surface is in an error state. This is currently + /// a simple boolean with no additional information on WHAT the + /// error state is, because we don't yet need it or use it. For now, + /// if this is true, then it means the terminal is non-functional. + @"error": bool = false, + + /// The source that handles setting our child property. + idle_rechild: ?c_uint = null, + + /// A weak reference to an inspector window. + inspector: ?*InspectorWindow = null, + + // True if the current surface is a split, this is used to apply + // unfocused-split-* options + is_split: bool = false, + + action_group: ?*gio.SimpleActionGroup = null, + + // Gtk.Scrollable interface adjustments + hadj: ?*gtk.Adjustment = null, + vadj: ?*gtk.Adjustment = null, + hscroll_policy: gtk.ScrollablePolicy = .natural, + vscroll_policy: gtk.ScrollablePolicy = .natural, + vadj_signal_group: ?*gobject.SignalGroup = null, + + // Key state tracking for key sequences and tables + key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty, + key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty, + + // Template binds + child_exited_overlay: *ChildExited, + context_menu: *gtk.PopoverMenu, + drop_target: *gtk.DropTarget, + progress_bar_overlay: *gtk.ProgressBar, + error_page: *adw.StatusPage, + terminal_page: *gtk.Overlay, + + /// The context for this surface (window, tab, or split) + context: apprt.surface.NewSurfaceContext = .window, + + /// Whether primary paste (middle-click paste) is enabled. + gtk_enable_primary_paste: bool = true, + + /// True when a left mouse down was consumed purely for a focus change, + /// and the matching left mouse release should also be suppressed. + suppress_left_mouse_release: bool = false, + + /// How much pending horizontal scroll do we have? + pending_horizontal_scroll: f64 = 0.0, + + /// Timer to reset the amount of horizontal scroll if the user + /// stops scrolling. + pending_horizontal_scroll_reset: ?c_uint = null, + + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + } = .none, + + pub var offset: c_int = 0; + }; + + pub fn new(overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) *Self { + const self = gobject.ext.newInstance(Self, .{ + .@"title-override" = overrides.title, + }); + const alloc = Application.default().allocator(); + const priv: *Private = self.private(); + priv.overrides = .{ + .command = if (overrides.command) |c| c.clone(alloc) catch null else null, + .working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null, + }; + return self; + } + + pub fn core(self: *Self) ?*CoreSurface { + const priv = self.private(); + return priv.core_surface; + } + + pub fn rt(self: *Self) *ApprtSurface { + const priv = self.private(); + return &priv.rt_surface; + } + + /// Set the parent of this surface. This will extract the information + /// required to initialize this surface with the proper values but doesn't + /// retain any memory. + /// + /// If the surface is already realized this does nothing. + pub fn setParent( + self: *Self, + parent: *CoreSurface, + context: apprt.surface.NewSurfaceContext, + ) void { + const priv = self.private(); + + // This is a mistake! We can only set a parent before surface + // realization. We log this because this is probably a logic error. + if (priv.core_surface != null) { + log.warn("setParent called after surface is already realized", .{}); + return; + } + + // Store the context so initSurface can use it + priv.context = context; + + // Setup our font size + const font_size_ptr = glib.ext.create(font.face.DesiredSize); + errdefer glib.ext.destroy(font_size_ptr); + font_size_ptr.* = parent.font_size; + priv.font_size_request = font_size_ptr; + self.as(gobject.Object).notifyByPspec(properties.@"font-size-request".impl.param_spec); + + // Remainder needs a config. If there is no config we just assume + // we aren't inheriting any of these values. + if (priv.config) |config_obj| { + // Setup our cwd if configured to inherit + if (apprt.surface.shouldInheritWorkingDirectory(context, config_obj.get())) { + if (parent.rt_surface.surface.getPwd()) |pwd| { + priv.pwd = glib.ext.dupeZ(u8, pwd); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + } + } + } + + /// Force the surface to redraw itself. Ghostty often will only redraw + /// the terminal in reaction to internal changes. If there are external + /// events that invalidate the surface, such as the widget moving parents, + /// then we should force a redraw. + pub fn redraw(self: *Self) void { + const priv = self.private(); + priv.gl_area.queueRender(); + } + + /// Callback used to determine whether border should be shown around the + /// surface. + fn closureShouldBorderBeShown( + _: *Self, + config_: ?*Config, + bell_ringing_: c_int, + ) callconv(.c) c_int { + const bell_ringing = bell_ringing_ != 0; + + // If the bell isn't ringing exit early because when the surface is + // first created there's a race between this code being run and the + // config being set on the surface. That way we don't overwhelm people + // with the warning that we issue if the config isn't set and overwhelm + // ourselves with large numbers of bug reports. + if (!bell_ringing) return @intFromBool(false); + + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computing whether border should be shown, likely bug", .{}); + return @intFromBool(false); + }; + + return @intFromBool(config.@"bell-features".border); + } + + /// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity + /// should be applied to the surface + fn closureShouldUnfocusedSplitBeShown( + _: *Self, + search_active: c_int, + focused: c_int, + is_split: c_int, + ) callconv(.c) c_int { + return @intFromBool(search_active == 0 and focused == 0 and is_split != 0); + } + + pub fn toggleFullscreen(self: *Self) void { + signals.@"toggle-fullscreen".impl.emit( + self, + null, + .{}, + null, + ); + } + + pub fn toggleMaximize(self: *Self) void { + signals.@"toggle-maximize".impl.emit( + self, + null, + .{}, + null, + ); + } + + pub fn toggleCommandPalette(self: *Self) bool { + // TODO: pass the surface with the action + return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; + } + + pub fn controlInspector( + self: *Self, + value: apprt.Action.Value(.inspector), + ) bool { + // Let's see if we have an inspector already. + const priv = self.private(); + if (priv.inspector) |inspector| switch (value) { + .show => {}, + // Our weak ref will set our private value to null + .toggle, .hide => inspector.as(gtk.Window).destroy(), + } else switch (value) { + .toggle, .show => { + const inspector = InspectorWindow.new(self); + inspector.present(); + inspector.as(gobject.Object).weakRef(inspectorWeakNotify, self); + priv.inspector = inspector; + }, + + .hide => {}, + } + + return true; + } + + /// Redraw our inspector, if there is one associated with this surface. + pub fn redrawInspector(self: *Self) void { + const priv = self.private(); + if (priv.inspector) |v| v.queueRender(); + } + + /// Handle a key sequence action from the apprt. + pub fn keySequenceAction( + self: *Self, + value: apprt.action.KeySequence, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec); + + switch (value) { + .trigger => |trigger| { + // Convert the trigger to a human-readable label + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| { + if (!success) return; + } else |_| return error.OutOfMemory; + + // Make space + try priv.key_sequence.ensureUnusedCapacity(alloc, 1); + + // Copy and append + const duped = try buf.toOwnedSliceSentinel(0); + errdefer alloc.free(duped); + priv.key_sequence.appendAssumeCapacity(duped); + }, + .end => { + // Free all the stored strings and clear + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.clearAndFree(alloc); + }, + } + } + + /// Handle a key table action from the apprt. + pub fn keyTableAction( + self: *Self, + value: apprt.action.KeyTable, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec); + + switch (value) { + .activate => |name| { + // Duplicate the name string and push onto stack + const duped = try alloc.dupeZ(u8, name); + errdefer alloc.free(duped); + try priv.key_tables.append(alloc, duped); + }, + .deactivate => { + // Pop and free the top table + if (priv.key_tables.pop()) |s| alloc.free(s); + }, + .deactivate_all => { + // Free all tables and clear + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.clearAndFree(alloc); + }, + } + } + + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { + const priv = self.private(); + return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; + } + + /// Set the scrollbar state for this surface. This will setup the + /// properties for our Gtk.Scrollable interface properly. + pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void { + // Update existing adjustment in-place. If we don't have an + // adjustment then we do nothing because we're not part of a + // scrolled window. + const vadj = self.getVAdjustment() orelse return; + + // Check if values match existing adjustment and skip update if so + const value: f64 = @floatFromInt(scrollbar.offset); + const upper: f64 = @floatFromInt(scrollbar.total); + const page_size: f64 = @floatFromInt(scrollbar.len); + + if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and + std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and + std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001)) + { + return; + } + + // If we have a vadjustment we MUST have the signal group since + // it is setup in the prop handler. + const priv = self.private(); + const group = priv.vadj_signal_group.?; + + // During manual scrollbar changes from Ghostty core we don't + // want to emit value-changed signals so we block them. This would + // cause a waste of resources at best and infinite loops at worst. + group.block(); + defer group.unblock(); + + vadj.configure( + value, // value: current scroll position + 0, // lower: minimum value + upper, // upper: maximum value (total scrollable area) + 1, // step_increment: amount to scroll on arrow click + page_size, // page_increment: amount to scroll on page up/down + page_size, // page_size: size of visible area + ); + } + + /// Set the current progress report state. + pub fn setProgressReport( + self: *Self, + value: terminal.osc.Command.ProgressReport, + ) void { + const priv = self.private(); + + // No matter what, we stop the timer because if we're removing + // then we're done and otherwise we restart it. + if (priv.progress_bar_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove progress bar timer", .{}); + } + priv.progress_bar_timer = null; + } + + if (priv.config) |config| { + if (!config.get().@"progress-style") { + log.debug("progress_report action blocked by config", .{}); + priv.progress_bar_overlay.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + } + } + + const progress_bar = priv.progress_bar_overlay; + switch (value.state) { + // Remove the progress bar + .remove => { + progress_bar.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + }, + + // Set the progress bar to a fixed value if one was provided, otherwise pulse. + // Remove the `error` CSS class so that the progress bar shows as normal. + .set => { + progress_bar.as(gtk.Widget).removeCssClass("error"); + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } else { + progress_bar.pulse(); + } + }, + + // Set the progress bar to a fixed value if one was provided, otherwise pulse. + // Set the `error` CSS class so that the progress bar shows as an error color. + .@"error" => { + progress_bar.as(gtk.Widget).addCssClass("error"); + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } else { + progress_bar.pulse(); + } + }, + + // The state of progress is unknown, so pulse the progress bar to + // indicate that things are still happening. + .indeterminate => { + progress_bar.pulse(); + }, + + // If a progress value was provided, set the progress bar to that value. + // Don't pulse the progress bar as that would indicate that things were + // happening. Otherwise this is mainly used to keep the progress bar on + // screen instead of timing out. + .pause => { + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } + }, + } + + // Assume all states lead to visibility + assert(value.state != .remove); + progress_bar.as(gtk.Widget).setVisible(@intFromBool(true)); + + // Start our timer to remove bad actor programs that stall + // the progress bar. + const progress_bar_timeout_seconds = 15; + assert(priv.progress_bar_timer == null); + priv.progress_bar_timer = glib.timeoutAdd( + progress_bar_timeout_seconds * std.time.ms_per_s, + progressBarTimer, + self, + ); + } + + /// The progress bar hasn't been updated by the TUI recently, remove it. + fn progressBarTimer(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud.?)); + const priv = self.private(); + priv.progress_bar_timer = null; + self.setProgressReport(.{ .state = .remove }); + return @intFromBool(glib.SOURCE_REMOVE); + } + + /// Request that this terminal come to the front and become focused. + /// It is up to the embedding widget to react to this. + pub fn present(self: *Self) void { + signals.@"present-request".impl.emit( + self, + null, + .{}, + null, + ); + } + + pub fn commandFinished(self: *Self, value: apprt.Action.Value(.command_finished)) bool { + const app = Application.default(); + const alloc = app.allocator(); + const priv: *Private = self.private(); + + const notify_next_command_finish = notify: { + const simple_action_group = priv.action_group orelse break :notify false; + const action_group = simple_action_group.as(gio.ActionGroup); + const state = action_group.getActionState("notify-on-next-command-finish") orelse break :notify false; + const bool_variant_type = glib.ext.VariantType.newFor(bool); + defer bool_variant_type.free(); + if (state.isOfType(bool_variant_type) == 0) break :notify false; + const notify = state.getBoolean() != 0; + action_group.changeActionState("notify-on-next-command-finish", glib.Variant.newBoolean(@intFromBool(false))); + break :notify notify; + }; + + const config = priv.config orelse return false; + + const cfg = config.get(); + + if (!notify_next_command_finish) { + if (cfg.@"notify-on-command-finish" == .never) return true; + if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true; + } + + if (value.duration.lte(cfg.@"notify-on-command-finish-after")) return true; + + const action = cfg.@"notify-on-command-finish-action"; + + if (action.bell) self.setBellRinging(true); + + if (action.notify) notify: { + const title_ = title: { + const exit_code = value.exit_code orelse break :title i18n._("Command Finished"); + if (exit_code == 0) break :title i18n._("Command Succeeded"); + break :title i18n._("Command Failed"); + }; + const title = std.mem.span(title_); + const body = body: { + const exit_code = value.exit_code orelse break :body std.fmt.allocPrintSentinel( + alloc, + "Command took {f}.", + .{value.duration.round(std.time.ns_per_ms)}, + 0, + ) catch break :notify; + break :body std.fmt.allocPrintSentinel( + alloc, + "Command took {f} and exited with code {d}.", + .{ value.duration.round(std.time.ns_per_ms), exit_code }, + 0, + ) catch break :notify; + }; + defer alloc.free(body); + + self.sendDesktopNotification(title, body); + } + + return true; + } + + /// Get the readonly state from the core surface. + pub fn getReadonly(self: *Self) bool { + const priv: *Private = self.private(); + const surface = priv.core_surface orelse return false; + return surface.readonly; + } + + /// Notify anyone interested that the readonly status has changed. + pub fn setReadonly(self: *Self, _: apprt.Action.Value(.readonly)) bool { + self.as(gobject.Object).notifyByPspec(properties.readonly.impl.param_spec); + + return true; + } + + /// Key press event (press or release). + /// + /// At a high level, we want to construct an `input.KeyEvent` and + /// pass that to `keyCallback`. At a low level, this is more complicated + /// than it appears because we need to construct all of this information + /// and its not given to us. + /// + /// For all events, we run the GdkEvent through the input method context. + /// This allows the input method to capture the event and trigger + /// callbacks such as preedit, commit, etc. + /// + /// There are a couple important aspects to the prior paragraph: we must + /// send ALL events through the input method context. This is because + /// input methods use both key press and key release events to determine + /// the state of the input method. For example, fcitx uses key release + /// events on modifiers (i.e. ctrl+shift) to switch the input method. + /// + /// We set some state to note we're in a key event (self.in_keyevent) + /// because some of the input method callbacks change behavior based on + /// this state. For example, we don't want to send character events + /// like "a" via the input "commit" event if we're actively processing + /// a keypress because we'd lose access to the keycode information. + /// However, a "commit" event may still happen outside of a keypress + /// event from e.g. a tablet or on-screen keyboard. + /// + /// Finally, we take all of the information in order to determine if we have + /// a unicode character or if we have to map the keyval to a code to + /// get the underlying logical key, etc. + /// + /// Then we can emit the keyCallback. + pub fn keyEvent( + self: *Surface, + action: input.Action, + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + ) bool { + //log.warn("keyEvent action={}", .{action}); + const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; + const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; + const priv = self.private(); + + // The block below is all related to input method handling. See the function + // comment for some high level details and then the comments within + // the block for more specifics. + { + // This can trigger an input method so we need to notify the im context + // where the cursor is so it can render the dropdowns in the correct + // place. + if (priv.core_surface) |surface| { + const ime_point = surface.imePoint(); + priv.im_context.as(gtk.IMContext).setCursorLocation(&.{ + .f_x = @intFromFloat(ime_point.x), + .f_y = @intFromFloat(ime_point.y), + .f_width = 1, + .f_height = 1, + }); + } + + // We note that we're in a keypress because we want some logic to + // depend on this. For example, we don't want to send character events + // like "a" via the input "commit" event if we're actively processing + // a keypress because we'd lose access to the keycode information. + // + // We have to maintain some additional state here of whether we + // were composing because different input methods call the callbacks + // in different orders. For example, ibus calls commit THEN preedit + // end but simple calls preedit end THEN commit. + priv.in_keyevent = if (priv.im_composing) .composing else .not_composing; + defer priv.in_keyevent = .false; + + // Pass the event through the input method which returns true if handled. + // Confusingly, not all events handled by the input method result + // in this returning true so we have to maintain some additional + // state about whether we were composing or not to determine if + // we should proceed with key encoding. + // + // Cases where the input method does not mark the event as handled: + // + // - If we change the input method via keypress while we have preedit + // text, the input method will commit the pending text but will not + // mark it as handled. We use the `.composing` state to detect + // this case. + // + // - If we switch input methods (i.e. via ctrl+shift with fcitx), + // the input method will handle the key release event but will not + // mark it as handled. I don't know any way to detect this case so + // it will result in a key event being sent to the key callback. + // For Kitty text encoding, this will result in modifiers being + // triggered despite being technically consumed. At the time of + // writing, both Kitty and Alacritty have the same behavior. I + // know of no way to fix this. + const im_handled = priv.im_context.as(gtk.IMContext).filterKeypress(event) != 0; + // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ + // im_handled, + // self.im_len, + // self.im_composing, + // }); + + // If the input method handled the event, you would think we would + // never proceed with key encoding for Ghostty but that is not the + // case. Input methods will handle basic character encoding like + // typing "a" and we want to associate that with the key event. + // So we have to check additional state to determine if we exit. + if (im_handled) { + // If we are composing then we're in a preedit state and do + // not want to encode any keys. For example: type a deadkey + // such as single quote on a US international keyboard layout. + if (priv.im_composing) return true; + + // If we were composing and now we're not, it means that we committed + // the text. We also don't want to encode a key event for this. + // Example: enable Japanese input method, press "konn" and then + // press enter. The final enter should not be encoded and "konn" + // (in hiragana) should be written as "こん". + if (priv.in_keyevent == .composing) return true; + + // Not composing and our input method buffer is empty. This could + // mean that the input method reacted to this event by activating + // an onscreen keyboard or something equivalent. We don't know. + // But the input method handled it and didn't give us text so + // we will just assume we should not encode this. This handles a + // real scenario when ibus starts the emoji input method + // (super+.). + if (priv.im_len == 0) return true; + } + + // At this point, for the sake of explanation of internal state: + // it is possible that im_len > 0 and im_composing == false. This + // means that we received a commit event from the input method that + // we want associated with the key event. This is common: its how + // basic character translation for simple inputs like "a" work. + } + + // We always reset the length of the im buffer. There's only one scenario + // we reach this point with im_len > 0 and that's if we received a commit + // event from the input method. We don't want to keep that state around + // since we've handled it here. + defer priv.im_len = 0; + + // Get the keyvals for this event. + const keyval_unicode = gdk.keyvalToUnicode(keyval); + const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( + priv.gl_area.as(gtk.Widget), + key_event, + keycode, + ); + + // We want to get the physical unmapped key to process physical keybinds. + // (These are keybinds explicitly marked as requesting physical mapping). + const physical_key = keycode: { + const w3c_key: input.Key = w3c: for (input.keycodes.entries) |entry| { + if (entry.native == keycode) break :w3c entry.key; + } else .unidentified; + + // Consult the pre-remapped XKB keyval/keysym to get the (possibly) + // remapped key. If the W3C key or the remapped key + // is eligible for remapping, we use it. + // + // See the docs for `shouldBeRemappable` for why we even have to + // do this in the first place. + if (gtk_key.keyFromKeyval(keyval)) |remapped| { + if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) + break :keycode remapped; + } + + // Return the original physical key + break :keycode w3c_key; + }; + + // Get our modifier for the event + const mods: input.Mods = gtk_key.eventMods( + event, + physical_key, + gtk_mods, + action, + Application.default().winproto(), + ); + + // Get our consumed modifiers + const consumed_mods: input.Mods = consumed: { + const T = @typeInfo(gdk.ModifierType); + std.debug.assert(T.@"struct".layout == .@"packed"); + const I = T.@"struct".backing_integer.?; + + const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK); + break :consumed gtk_key.translateMods(@bitCast(masked)); + }; + + // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ + // key, + // keyval, + // physical_key, + // priv.im_composing, + // priv.im_len, + // mods, + // }); + + // If we have no UTF-8 text, we try to convert our keyval to + // a text value. We have to do this because GTK will not process + // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". + // But the keyval is set correctly so we can at least extract that. + if (priv.im_len == 0 and keyval_unicode > 0) im: { + if (std.math.cast(u21, keyval_unicode)) |cp| { + // We don't want to send control characters as IM + // text. Control characters are handled already by + // the encoder directly. + if (cp < 0x20) break :im; + + if (std.unicode.utf8Encode(cp, &priv.im_buf)) |len| { + priv.im_len = len; + } else |_| {} + } + } + + // Invoke the core Ghostty logic to handle this input. + const surface = priv.core_surface orelse return false; + const effect = surface.keyCallback(.{ + .action = action, + .key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = priv.im_composing, + .utf8 = priv.im_buf[0..priv.im_len], + .unshifted_codepoint = keyval_unicode_unshifted, + }) catch |err| { + log.err("error in key callback err={}", .{err}); + return false; + }; + + switch (effect) { + .closed => return true, + .ignored => {}, + .consumed => if (action == .press or action == .repeat) { + // If we were in the composing state then we reset our context. + // We do NOT want to reset if we're not in the composing state + // because there is other IME state that we want to preserve, + // such as quotation mark ordering for Chinese input. + if (priv.im_composing) { + priv.im_context.as(gtk.IMContext).reset(); + surface.preeditCallback(null) catch {}; + } + + // Bell stops ringing when any key is pressed that is used by + // the core in any way. + self.setBellRinging(false); + + return true; + }, + } + + return false; + } + + /// Prompt for a manual title change for the surface. + pub fn promptTitle(self: *Self) void { + const priv = self.private(); + const dialog = TitleDialog.new(.surface, priv.title_override orelse priv.title); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + /// Scale x/y by the GDK device scale. + fn scaledCoordinates( + self: *Self, + x: f64, + y: f64, + ) struct { x: f64, y: f64 } { + const gl_area = self.private().gl_area; + const scale_factor: f64 = @floatFromInt( + gl_area.as(gtk.Widget).getScaleFactor(), + ); + + return .{ + .x = x * scale_factor, + .y = y * scale_factor, + }; + } + + //--------------------------------------------------------------- + // Libghostty Callbacks + + pub fn close(self: *Self) void { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + } + + pub fn childExited( + self: *Self, + data: apprt.surface.Message.ChildExited, + ) bool { + // Even if we don't support the overlay, we still keep our property + // up to date for anyone listening. + const priv = self.private(); + priv.child_exited = true; + self.as(gobject.Object).notifyByPspec( + properties.@"child-exited".impl.param_spec, + ); + + // If we have the noop child exited overlay then we don't do anything + // for child exited. The false return will force libghostty to show + // the normal text-based message. + if (comptime @hasDecl(ChildExited, "noop")) { + return false; + } + + priv.child_exited_overlay.setData(&data); + return true; + } + + pub fn getContentScale(self: *Self) apprt.ContentScale { + const priv = self.private(); + const gl_area = priv.gl_area; + + const gtk_scale: f32 = scale: { + const widget = gl_area.as(gtk.Widget); + // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we + // can support fractional scaling. + const scale = widget.getScaleFactor(); + if (scale <= 0) { + log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); + break :scale 1.0; + } + break :scale @floatFromInt(scale); + }; + + // Also scale using font-specific DPI, which is often exposed to the user + // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). + const xft_dpi_scale = xft_scale: { + // gtk-xft-dpi is font DPI multiplied by 1024. See + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + const gtk_xft_dpi = gsettings.get(.@"gtk-xft-dpi") orelse { + log.warn("gtk-xft-dpi was not set, using default value", .{}); + break :xft_scale 1.0; + }; + + // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 + // See: + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead + if (gtk_xft_dpi <= 0) { + log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); + break :xft_scale 1.0; + } + + // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by + // 1024, then divide by the default value (96) to derive a scale. Note + // gtk-xft-dpi can be fractional, so we use floating point math here. + const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024.0; + break :xft_scale xft_dpi / 96.0; + }; + + const scale = gtk_scale * xft_dpi_scale; + return .{ .x = scale, .y = scale }; + } + + pub fn getSize(self: *Self) apprt.SurfaceSize { + const priv = self.private(); + // By the time this is called, we should be in a widget tree. + // This should not be called before that. We ensure this by initializing + // the surface in `glareaResize`. This is VERY important because it + // avoids the pty having an incorrect initial size. + assert(priv.size.width >= 0 and priv.size.height >= 0); + return priv.size; + } + + pub fn getCursorPos(self: *Self) apprt.CursorPos { + return self.private().cursor_pos; + } + + pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + const app = Application.default(); + const alloc = app.allocator(); + var env = try internal_os.getEnvMap(alloc); + errdefer env.deinit(); + + if (app.savedLanguage()) |language| { + try env.put("LANG", language); + } else { + env.remove("LANG"); + } + + // Don't leak these GTK environment variables to child processes. + env.remove("GDK_DEBUG"); + env.remove("GDK_DISABLE"); + env.remove("GSK_RENDERER"); + + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + env.remove("NOTIFY_SOCKET"); + + // Unset environment varies set by snaps if we're running in a snap. + // This allows Ghostty to further launch additional snaps. + if (comptime build_config.snap) { + if (env.get("SNAP") != null) try filterSnapPaths( + alloc, + &env, + ); + } + + // This is a hack because it ties ourselves (optionally) to the + // Window class. The right solution we should do is emit a signal + // here where the handler can modify our EnvMap, but boxing the + // EnvMap is a bit annoying so I'm punting it. + if (ext.getAncestor(Window, self.as(gtk.Widget))) |window| { + try window.winproto().addSubprocessEnv(&env); + + if (window.isQuickTerminal()) { + try env.put("GHOSTTY_QUICK_TERMINAL", "1"); + } + } + + return env; + } + + /// Filter out environment variables that start with forbidden prefixes. + fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void { + comptime assert(build_config.snap); + + const snap_vars = [_][]const u8{ + "SNAP", + "SNAP_USER_COMMON", + "SNAP_USER_DATA", + "SNAP_DATA", + "SNAP_COMMON", + }; + + // Use an arena because everything in this function is temporary. + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var env_to_remove: std.ArrayList([]const u8) = .empty; + var env_to_update: std.ArrayList(struct { + key: []const u8, + value: []const u8, + }) = .empty; + + var it = env_map.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + const value = entry.value_ptr.*; + + // Ignore fields we set ourself + if (std.mem.eql(u8, key, "TERMINFO")) continue; + if (std.mem.startsWith(u8, key, "GHOSTTY")) continue; + + // Any env var starting with SNAP must be removed + if (std.mem.startsWith(u8, key, "SNAP_")) { + try env_to_remove.append(alloc, key); + continue; + } + + var filtered_paths: std.ArrayList([]const u8) = .empty; + var modified = false; + var paths = std.mem.splitAny(u8, value, ":"); + while (paths.next()) |path| { + var include = true; + for (snap_vars) |k| if (env_map.get(k)) |snap_path| { + if (snap_path.len == 0) continue; + if (std.mem.startsWith(u8, path, snap_path)) { + include = false; + modified = true; + break; + } + }; + if (include) try filtered_paths.append(alloc, path); + } + + if (modified) { + if (filtered_paths.items.len > 0) { + const new_value = try std.mem.join(alloc, ":", filtered_paths.items); + try env_to_update.append(alloc, .{ .key = key, .value = new_value }); + } else { + try env_to_remove.append(alloc, key); + } + } + } + + for (env_to_update.items) |item| try env_map.put( + item.key, + item.value, + ); + for (env_to_remove.items) |key| _ = env_map.remove(key); + } + + pub fn clipboardRequest( + self: *Self, + clipboard_type: apprt.Clipboard, + state: apprt.ClipboardRequest, + ) !bool { + return try Clipboard.request( + self, + clipboard_type, + state, + ); + } + + pub fn setClipboard( + self: *Self, + clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, + confirm: bool, + ) void { + Clipboard.set( + self, + clipboard_type, + contents, + confirm, + ); + } + + /// Focus this surface. This properly focuses the input part of + /// our surface. + pub fn grabFocus(self: *Self) void { + const priv = self.private(); + _ = priv.gl_area.as(gtk.Widget).grabFocus(); + } + + pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void { + const app = Application.default(); + const priv: *Private = self.private(); + + const core_surface = priv.core_surface orelse { + log.warn("can't send notification because there is no core surface", .{}); + return; + }; + + const t = switch (title.len) { + 0 => "Ghostty", + else => title, + }; + + const notification = gio.Notification.new(t); + defer notification.unref(); + notification.setBody(body); + + const icon = gio.ThemedIcon.new("com.mitchellh.ghostty"); + defer icon.unref(); + notification.setIcon(icon.as(gio.Icon)); + + const pointer = glib.Variant.newUint64(core_surface.id); + notification.setDefaultActionAndTargetValue( + "app.present-surface", + pointer, + ); + + // We set the notification ID to the body content. If the content is the + // same, this notification may replace a previous notification + const gio_app = app.as(gio.Application); + gio_app.sendNotification(body, notification); + } + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Initialize our actions + self.initActionMap(); + + const priv = self.private(); + + // Initialize some private fields so they aren't undefined + priv.rt_surface = .{ .surface = self }; + priv.precision_scroll = false; + priv.cursor_pos = .{ .x = 0, .y = 0 }; + priv.mouse_shape = .text; + priv.mouse_hidden = false; + priv.focused = true; + priv.size = .{ .width = 0, .height = 0 }; + priv.vadj_signal_group = null; + + // If our configuration is null then we get the configuration + // from the application. + if (priv.config == null) { + const app = Application.default(); + priv.config = app.getConfig(); + } + + // Setup our input method state + priv.in_keyevent = .false; + priv.im_composing = false; + priv.im_len = 0; + + // Read GTK primary paste setting + priv.gtk_enable_primary_paste = gsettings.get(.@"gtk-enable-primary-paste") orelse true; + + // Set up to handle items being dropped on our surface. Files can be dropped + // from Nautilus and strings can be dropped from many programs. The order + // of these types matter. + var drop_target_types = [_]gobject.Type{ + gdk.FileList.getGObjectType(), + gio.File.getGObjectType(), + gobject.ext.types.string, + }; + priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); + + // Setup properties we can't set from our Blueprint file. + self.as(gtk.Widget).setCursorFromName("text"); + + // Initialize our config + self.propConfig(undefined, null); + } + + fn initActionMap(self: *Self) void { + const priv: *Private = self.private(); + + const actions = [_]ext.actions.Action(Self){ + .init( + "prompt-title", + actionPromptTitle, + null, + ), + .initStateful( + "notify-on-next-command-finish", + actionNotifyOnNextCommandFinish, + null, + glib.Variant.newBoolean(@intFromBool(false)), + ), + }; + + priv.action_group = ext.actions.addAsGroup(Self, self, "surface", &actions); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + if (priv.vadj_signal_group) |group| { + group.setTarget(null); + group.as(gobject.Object).unref(); + priv.vadj_signal_group = null; + } + + if (priv.hadj) |v| { + v.as(gobject.Object).unref(); + priv.hadj = null; + } + + if (priv.vadj) |v| { + v.as(gobject.Object).unref(); + priv.vadj = null; + } + + if (priv.progress_bar_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove progress bar timer", .{}); + } + priv.progress_bar_timer = null; + } + + if (priv.idle_rechild) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle_rechild = null; + } + + if (priv.pending_horizontal_scroll_reset) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove pending horizontal scroll reset source", .{}); + } + priv.pending_horizontal_scroll_reset = null; + } + + // This works around a GTK double-free bug where if you bind + // to a top-level template child, it frees twice if the widget is + // also the root child of the template. By unsetting the child here, + // we avoid the double-free. + self.as(adw.Bin).setChild(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const alloc = Application.default().allocator(); + const priv = self.private(); + if (priv.core_surface) |v| { + // Remove ourselves from the list of known surfaces in the app. + // We do this before deinit in case a callback triggers + // searching for this surface. + Application.default().core().deleteSurface(self.rt()); + + // NOTE: We must deinit the surface in the finalize call and NOT + // the dispose call because the inspector widget relies on this + // behavior with a weakRef to properly deactivate. + + // Deinit the surface + v.deinit(); + alloc.destroy(v); + + priv.core_surface = null; + } + if (priv.mouse_hover_url) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.mouse_hover_url = null; + } + if (priv.default_size) |v| { + ext.boxedFree(Size, v); + priv.default_size = null; + } + if (priv.font_size_request) |v| { + glib.ext.destroy(v); + priv.font_size_request = null; + } + if (priv.min_size) |v| { + ext.boxedFree(Size, v); + priv.min_size = null; + } + if (priv.pwd) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.pwd = null; + } + if (priv.title) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.title = null; + } + if (priv.title_override) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + } + if (priv.overrides.command) |c| { + c.deinit(alloc); + priv.overrides.command = null; + } + if (priv.overrides.working_directory) |wd| { + alloc.free(wd); + priv.overrides.working_directory = null; + } + + // Clean up key sequence and key table state + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.deinit(alloc); + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.deinit(alloc); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Properties + + /// Returns the title property without a copy. + pub fn getTitle(self: *Self) ?[:0]const u8 { + return self.private().title; + } + + /// Returns the effective title: the user-overridden title if set, + /// otherwise the terminal-set title. + pub fn getEffectiveTitle(self: *Self) ?[:0]const u8 { + const priv = self.private(); + return priv.title_override orelse priv.title; + } + + /// Copies the effective title to the clipboard. + pub fn copyTitleToClipboard(self: *Self) bool { + const title = self.getEffectiveTitle() orelse return false; + if (title.len == 0) return false; + self.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = title, + }}, false); + return true; + } + + /// Set the title for this surface, copies the value. This should always + /// be the title as set by the terminal program, not any manually set + /// title. For manually set titles see `setTitleOverride`. + pub fn setTitle(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title) |v| glib.free(@ptrCast(@constCast(v))); + priv.title = null; + if (title) |v| priv.title = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); + } + + /// Overridden title. This will be generally be shown over the title + /// unless this is unset (null). + pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + + /// Returns the pwd property without a copy. + pub fn getPwd(self: *Self) ?[:0]const u8 { + return self.private().pwd; + } + + /// Set the pwd for this surface, copies the value. + pub fn setPwd(self: *Self, pwd: ?[:0]const u8) void { + const priv = self.private(); + if (priv.pwd) |v| glib.free(@ptrCast(@constCast(v))); + priv.pwd = null; + if (pwd) |v| priv.pwd = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + + /// Returns the focus state of this surface. + pub fn getFocused(self: *Self) bool { + return self.private().focused; + } + + /// Change the configuration for this surface. + pub fn setConfig(self: *Self, config: *Config) void { + const priv = self.private(); + if (priv.config) |c| c.unref(); + priv.config = config.ref(); + self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + } + + /// Return the default size, if set. + pub fn getDefaultSize(self: *Self) ?*Size { + const priv = self.private(); + return priv.default_size; + } + + /// Set the default size for a window that contains this surface. + /// This is up to the embedding widget to respect this. Generally, only + /// the first surface in a window respects this. + pub fn setDefaultSize(self: *Self, size: Size) void { + const priv = self.private(); + if (priv.default_size) |v| ext.boxedFree( + Size, + v, + ); + priv.default_size = ext.boxedCopy( + Size, + &size, + ); + self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec); + } + + /// Estimate and set the initial window size from config and font metrics. + /// This can be called before the core surface exists to set up the window + /// size before presenting. This is an estimate because it does not take + /// into account any padding that may need to be added to the window. + pub fn estimateInitialSize(self: *Self) void { + const priv: *Private = self.private(); + const config_obj = priv.config orelse return; + const config = config_obj.get(); + + // Both dimensions must be configured + if (config.@"window-height" <= 0 or config.@"window-width" <= 0) return; + + const app = Application.default(); + const alloc = app.allocator(); + + // Get content scale and compute DPI + const content_scale = self.getContentScale(); + const x_dpi = content_scale.x * font.face.default_dpi; + const y_dpi = content_scale.y * font.face.default_dpi; + + const font_size: font.face.DesiredSize = .{ + .points = config.@"font-size", + .xdpi = @intFromFloat(x_dpi), + .ydpi = @intFromFloat(y_dpi), + }; + + // Get font grid for cell metrics + var derived_config = font.SharedGridSet.DerivedConfig.init(alloc, config) catch return; + defer derived_config.deinit(); + + const font_grid_key, const font_grid = app.core().font_grid_set.ref( + &derived_config, + font_size, + ) catch return; + defer app.core().font_grid_set.deref(font_grid_key); + + const cell = font_grid.cellSize(); + + const width = @max(CoreSurface.min_window_width_cells, config.@"window-width") * cell.width; + const height = @max(CoreSurface.min_window_height_cells, config.@"window-height") * cell.height; + const width_f32: f32 = @floatFromInt(width); + const height_f32: f32 = @floatFromInt(height); + + const final_width: u32 = @intFromFloat(@ceil(width_f32 / content_scale.x)); + const final_height: u32 = @intFromFloat(@ceil(height_f32 / content_scale.y)); + + self.setDefaultSize(.{ .width = final_width, .height = final_height }); + } + + /// Get the key sequence list. Full transfer. + fn getKeySequence(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_sequence.items) catch null; + } + + /// Get the key table list. Full transfer. + fn getKeyTable(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_tables.items) catch null; + } + + /// Return the min size, if set. + pub fn getMinSize(self: *Self) ?*Size { + const priv = self.private(); + return priv.min_size; + } + + /// Set the min size for a window that contains this surface. + /// This is up to the embedding widget to respect this. Generally, only + /// the first surface in a window respects this. + pub fn setMinSize(self: *Self, size: Size) void { + const priv = self.private(); + if (priv.min_size) |v| ext.boxedFree( + Size, + v, + ); + priv.min_size = ext.boxedCopy( + Size, + &size, + ); + self.as(gobject.Object).notifyByPspec(properties.@"min-size".impl.param_spec); + } + + pub fn getMouseShape(self: *Self) terminal.MouseShape { + return self.private().mouse_shape; + } + + pub fn setMouseShape(self: *Self, shape: terminal.MouseShape) void { + const priv = self.private(); + priv.mouse_shape = shape; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-shape".impl.param_spec); + } + + pub fn getMouseHidden(self: *Self) bool { + return self.private().mouse_hidden; + } + + pub fn setMouseHidden(self: *Self, hidden: bool) void { + const priv = self.private(); + priv.mouse_hidden = hidden; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hidden".impl.param_spec); + } + + pub fn setMouseHoverUrl(self: *Self, url: ?[:0]const u8) void { + const priv = self.private(); + if (priv.mouse_hover_url) |v| glib.free(@ptrCast(@constCast(v))); + priv.mouse_hover_url = null; + if (url) |v| priv.mouse_hover_url = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); + } + + pub fn getBellRinging(self: *Self) bool { + return self.private().bell_ringing; + } + + pub fn setBellRinging(self: *Self, ringing: bool) void { + // Prevent duplicate change notifications if the signals we emit + // in this function cause this state to change again. + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + + // Logic around bell reaction happens on every event even if we're + // already in the ringing state. + if (ringing) self.ringBell(); + + // Property change only happens on actual state change + const priv = self.private(); + if (priv.bell_ringing == ringing) return; + priv.bell_ringing = ringing; + self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec); + } + + pub fn setError(self: *Self, v: bool) void { + const priv = self.private(); + priv.@"error" = v; + self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); + } + + pub fn setSearchActive(self: *Self, active: bool, needle: [:0]const u8) void { + const priv = self.private(); + var value = gobject.ext.Value.newFrom(active); + defer value.unset(); + gobject.Object.setProperty( + priv.search_overlay.as(gobject.Object), + SearchOverlay.properties.active.name, + &value, + ); + + if (!std.mem.eql(u8, needle, "")) { + priv.search_overlay.setSearchContents(needle); + } + + if (active) { + priv.search_overlay.grabFocus(); + } + } + + pub fn setSearchTotal(self: *Self, total: ?usize) void { + self.private().search_overlay.setSearchTotal(total); + } + + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + self.private().search_overlay.setSearchSelected(selected); + } + + fn propConfig( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const config = if (priv.config) |c| c.get() else return; + + // resize-overlay-duration + { + const ms = config.@"resize-overlay-duration".asMilliseconds(); + var value = gobject.ext.Value.newFrom(ms); + defer value.unset(); + gobject.Object.setProperty( + priv.resize_overlay.as(gobject.Object), + "duration", + &value, + ); + } + + // resize-overlay-position + { + const hv: struct { + gtk.Align, // halign + gtk.Align, // valign + } = switch (config.@"resize-overlay-position") { + .center => .{ .center, .center }, + .@"top-left" => .{ .start, .start }, + .@"top-right" => .{ .end, .start }, + .@"top-center" => .{ .center, .start }, + .@"bottom-left" => .{ .start, .end }, + .@"bottom-right" => .{ .end, .end }, + .@"bottom-center" => .{ .center, .end }, + }; + + var halign = gobject.ext.Value.newFrom(hv[0]); + defer halign.unset(); + var valign = gobject.ext.Value.newFrom(hv[1]); + defer valign.unset(); + gobject.Object.setProperty( + priv.resize_overlay.as(gobject.Object), + "overlay-halign", + &halign, + ); + gobject.Object.setProperty( + priv.resize_overlay.as(gobject.Object), + "overlay-valign", + &valign, + ); + } + } + + fn propError( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (priv.@"error") { + // Ensure we have an opaque background. The window will NOT set + // this if we have transparency set and we need an opaque + // background for the error message to be readable. + self.as(gtk.Widget).addCssClass("background"); + } else { + // Regardless of transparency setting, we remove the background + // CSS class from this widget. Parent widgets will set it + // appropriately (see window.zig for example). + self.as(gtk.Widget).removeCssClass("background"); + } + + // We need to set our child property on an idle tick, because the + // error property can be triggered by signals that are in the middle + // of widget mapping and changing our child during that time + // results in a hard gtk crash. + if (priv.idle_rechild == null) priv.idle_rechild = glib.idleAdd( + onIdleRechild, + self, + ); + } + + fn onIdleRechild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.idle_rechild = null; + if (priv.@"error") { + self.as(adw.Bin).setChild(priv.error_page.as(gtk.Widget)); + } else { + self.as(adw.Bin).setChild(priv.terminal_page.as(gtk.Widget)); + } + return 0; + } + + fn propMouseHoverUrl( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const visible = if (priv.mouse_hover_url) |v| v.len > 0 else false; + priv.url_left.as(gtk.Widget).setVisible(if (visible) 1 else 0); + } + + fn propMouseHidden( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // If we're hidden we set it to "none" + if (priv.mouse_hidden) { + self.as(gtk.Widget).setCursorFromName("none"); + return; + } + + // If we're not hidden we just trigger the mouse shape + // prop notification to handle setting the proper mouse shape. + self.propMouseShape(undefined, null); + } + + fn propMouseShape( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // If our mouse should be hidden currently then we don't + // do anything. + if (priv.mouse_hidden) return; + + const name: [:0]const u8 = switch (priv.mouse_shape) { + .default => "default", + .help => "help", + .pointer => "pointer", + .context_menu => "context-menu", + .progress => "progress", + .wait => "wait", + .cell => "cell", + .crosshair => "crosshair", + .text => "text", + .vertical_text => "vertical-text", + .alias => "alias", + .copy => "copy", + .no_drop => "no-drop", + .move => "move", + .not_allowed => "not-allowed", + .grab => "grab", + .grabbing => "grabbing", + .all_scroll => "all-scroll", + .col_resize => "col-resize", + .row_resize => "row-resize", + .n_resize => "n-resize", + .e_resize => "e-resize", + .s_resize => "s-resize", + .w_resize => "w-resize", + .ne_resize => "ne-resize", + .nw_resize => "nw-resize", + .se_resize => "se-resize", + .sw_resize => "sw-resize", + .ew_resize => "ew-resize", + .ns_resize => "ns-resize", + .nesw_resize => "nesw-resize", + .nwse_resize => "nwse-resize", + .zoom_in => "zoom-in", + .zoom_out => "zoom-out", + }; + + // Set our new cursor. + self.as(gtk.Widget).setCursorFromName(name.ptr); + } + + fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void { + // This will trigger for every single pixel change in the adjustment, + // but our core surface handles the noise from this so that identical + // rows are cheap. + const core_surface = self.core() orelse return; + const row: usize = @intFromFloat(@round(adj.getValue())); + _ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| { + log.err("error performing scroll_to_row action err={}", .{err}); + }; + } + + fn propVAdjustment( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // When vadjustment is first set, we setup the signal group lazily. + // This makes it so that if we don't use scrollbars, we never + // pay the memory cost of this. + const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: { + const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType()); + group.connect( + "value-changed", + @ptrCast(&vadjValueChanged), + self, + ); + + priv.vadj_signal_group = group; + break :group group; + }; + + // Setup our signal group target + group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null); + } + + /// Handle bell features that need to happen every time a BEL is received + /// Currently this is audio and system but this could change in the future. + fn ringBell(self: *Self) void { + const priv = self.private(); + + // Emit the signal + signals.bell.impl.emit( + self, + null, + .{}, + null, + ); + + // Activate actions if they exist + _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); + _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); + + const config = if (priv.config) |c| c.get() else return; + + // Do our sound + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + const media_file = media.fromFilename(path) orelse break :audio; + media.playMediaFile(media_file, volume, required); + } + } + + //--------------------------------------------------------------- + // Gtk.Scrollable interface implementation + + pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().hadj; + } + + pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec); + + const priv = self.private(); + if (priv.hadj) |old| { + old.as(gobject.Object).unref(); + priv.hadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.hadj = adj; + } + + fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getHAdjustment()); + } + + fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + + pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().vadj; + } + + pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec); + + const priv = self.private(); + + if (priv.vadj) |old| { + old.as(gobject.Object).unref(); + priv.vadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.vadj = adj; + } + + fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getVAdjustment()); + } + + fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + + //--------------------------------------------------------------- + // Signal Handlers + + pub fn actionPromptTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.prompt_surface_title) catch |err| { + log.warn("unable to perform prompt title action err={}", .{err}); + }; + } + + pub fn actionNotifyOnNextCommandFinish( + action: *gio.SimpleAction, + _: ?*glib.Variant, + _: *Self, + ) callconv(.c) void { + const state = action.as(gio.Action).getState() orelse glib.Variant.newBoolean(@intFromBool(false)); + defer state.unref(); + const bool_variant_type = glib.ext.VariantType.newFor(bool); + defer bool_variant_type.free(); + if (state.isOfType(bool_variant_type) == 0) return; + const value = state.getBoolean() != 0; + action.setState(glib.Variant.newBoolean(@intFromBool(!value))); + } + + fn childExitedClose( + _: *ChildExited, + self: *Self, + ) callconv(.c) void { + // This closes the surface with no confirmation. + self.close(); + } + + fn contextMenuClosed( + _: *gtk.PopoverMenu, + self: *Self, + ) callconv(.c) void { + // When the context menu closes, it moves focus back to the tab + // bar if there are tabs. That's not correct. We need to grab it + // on the surface. + self.grabFocus(); + } + + fn inspectorWeakNotify( + ud: ?*anyopaque, + _: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + priv.inspector = null; + } + + fn dtDrop( + _: *gtk.DropTarget, + value: *gobject.Value, + _: f64, + _: f64, + self: *Self, + ) callconv(.c) c_int { + const alloc = Application.default().allocator(); + + if (ext.gValueHolds(value, gdk.FileList.getGObjectType())) { + var stream: std.Io.Writer.Allocating = .init(alloc); + defer stream.deinit(); + + var shell_escape_writer: internal_os.ShellEscapeWriter = .init(&stream.writer); + const writer = &shell_escape_writer.writer; + + const list: ?*glib.SList = list: { + const unboxed = value.getBoxed() orelse return 0; + const fl: *gdk.FileList = @ptrCast(@alignCast(unboxed)); + break :list fl.getFiles(); + }; + defer if (list) |v| v.free(); + + { + var current: ?*glib.SList = list; + while (current) |item| : (current = item.f_next) { + const file: *gio.File = @ptrCast(@alignCast(item.f_data orelse continue)); + const path = file.getPath() orelse continue; + const slice = std.mem.span(path); + defer glib.free(path); + + writer.writeAll(slice) catch |err| { + log.err("unable to write path to buffer: {}", .{err}); + continue; + }; + writer.writeAll("\n") catch |err| { + log.err("unable to write to buffer: {}", .{err}); + continue; + }; + } + } + + const string = stream.toOwnedSliceSentinel(0) catch |err| { + log.err("unable to convert to a slice: {}", .{err}); + return 0; + }; + defer alloc.free(string); + Clipboard.paste(self, string); + return 1; + } + + if (ext.gValueHolds(value, gio.File.getGObjectType())) { + const object = value.getObject() orelse return 0; + const file = gobject.ext.cast(gio.File, object) orelse return 0; + const path = file.getPath() orelse return 0; + var stream: std.Io.Writer.Allocating = .init(alloc); + defer stream.deinit(); + + var shell_escape_writer: internal_os.ShellEscapeWriter = .init(&stream.writer); + const writer = &shell_escape_writer.writer; + writer.writeAll(std.mem.span(path)) catch |err| { + log.err("unable to write path to buffer: {}", .{err}); + return 0; + }; + writer.writeAll("\n") catch |err| { + log.err("unable to write to buffer: {}", .{err}); + return 0; + }; + + const string = stream.toOwnedSliceSentinel(0) catch |err| { + log.err("unable to convert to a slice: {}", .{err}); + return 0; + }; + defer alloc.free(string); + return 1; + } + + if (ext.gValueHolds(value, gobject.ext.types.string)) { + if (value.getString()) |string| { + Clipboard.paste(self, std.mem.span(string)); + } + return 1; + } + + return 1; + } + + fn ecKeyPressed( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + return @intFromBool(self.keyEvent( + .press, + ec_key, + keyval, + keycode, + gtk_mods, + )); + } + + fn ecKeyReleased( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + state: gdk.ModifierType, + self: *Self, + ) callconv(.c) void { + _ = self.keyEvent( + .release, + ec_key, + keyval, + keycode, + state, + ); + } + + fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.updateFocus(true); + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.updateFocus(false); + } + + fn updateFocus(self: *Self, focused: bool) void { + const priv = self.private(); + priv.focused = focused; + + const ctx = priv.im_context.as(gtk.IMContext); + if (focused) ctx.focusIn() else ctx.focusOut(); + + _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); + + // Bell stops ringing as soon as we gain focus + if (focused) self.setBellRinging(false); + } + + /// The focus callback must be triggered on an idle loop source because + /// there are actions within libghostty callbacks (such as showing close + /// confirmation dialogs) that can trigger focus loss and cause a deadlock + /// because the lock may be held during the callback. + /// + /// Userdata should be a `*Surface`. This will unref once. + fn idleFocus(ud: ?*anyopaque) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + defer self.unref(); + + const priv = self.private(); + const surface = priv.core_surface orelse return; + surface.focusCallback(priv.focused) catch |err| { + log.warn("error in focus callback err={}", .{err}); + }; + } + + fn gcMouseDown( + gesture: *gtk.GestureClick, + _: c_int, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + + // Bell stops ringing if any mouse button is pressed. + self.setBellRinging(false); + + // Get our surface. If we don't have one, ignore this. + const priv = self.private(); + const core_surface = priv.core_surface orelse return; + + // If we don't have focus, grab it. + const gl_area_widget = priv.gl_area.as(gtk.Widget); + const had_focus = gl_area_widget.hasFocus() != 0; + if (!had_focus) { + _ = gl_area_widget.grabFocus(); + } + + // Report the event + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + + // If this click is only transitioning split focus, suppress it so + // it doesn't get forwarded to the terminal as a mouse event. + if (!had_focus and button == .left) { + priv.suppress_left_mouse_release = true; + return; + } + + if (button == .middle and !priv.gtk_enable_primary_paste) { + return; + } + + const consumed = consumed: { + const gtk_mods = event.getModifierState(); + const mods = gtk_key.translateMods(gtk_mods); + break :consumed core_surface.mouseButtonCallback( + .press, + button, + mods, + ) catch |err| err: { + log.warn("error in key callback err={}", .{err}); + break :err false; + }; + }; + + // If a right click isn't consumed, mouseButtonCallback selects the hovered + // word and returns false. We can use this to handle the context menu + // opening under normal scenarios. + if (!consumed and button == .right) { + signals.menu.impl.emit( + self, + null, + .{}, + null, + ); + + const rect: gdk.Rectangle = .{ + .f_x = @intFromFloat(x), + .f_y = @intFromFloat(y), + .f_width = 1, + .f_height = 1, + }; + + const popover = priv.context_menu.as(gtk.Popover); + popover.setPointingTo(&rect); + popover.popup(); + } + } + + fn gcMouseUp( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *Self, + ) callconv(.c) void { + const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + + const priv = self.private(); + const surface = priv.core_surface orelse return; + const gtk_mods = event.getModifierState(); + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + + if (button == .left and priv.suppress_left_mouse_release) { + priv.suppress_left_mouse_release = false; + return; + } + + if (button == .middle and !priv.gtk_enable_primary_paste) { + return; + } + + const mods = gtk_key.translateMods(gtk_mods); + const consumed = surface.mouseButtonCallback( + .release, + button, + mods, + ) catch |err| { + log.warn("error in key callback err={}", .{err}); + return; + }; + + // Trigger the on-screen keyboard if we have no selection, + // and that the mouse event hasn't been intercepted by the callback. + // + // It's better to do this here rather than within the core callback + // since we have direct access to the underlying gdk.Event here. + if (!consumed and button == .left and !surface.hasSelection()) { + if (!self.showOnScreenKeyboard(event)) { + log.warn("failed to activate the on-screen keyboard", .{}); + } + } + } + + fn ecMouseMotion( + ec: *gtk.EventControllerMotion, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const event = ec.as(gtk.EventController).getCurrentEvent() orelse return; + const priv = self.private(); + + const scaled = self.scaledCoordinates(x, y); + const pos: apprt.CursorPos = .{ + .x = @floatCast(scaled.x), + .y = @floatCast(scaled.y), + }; + + // There seem to be at least two cases where GTK issues a mouse motion + // event without the cursor actually moving: + // 1. GLArea is resized under the mouse. This has the unfortunate + // side effect of causing focus to potentially change when + // `focus-follows-mouse` is enabled. + // 2. The window title is updated. This can cause the mouse to unhide + // incorrectly when hide-mouse-when-typing is enabled. + // To prevent incorrect behavior, we'll only grab focus and + // continue with callback logic if the cursor has actually moved. + const is_cursor_still = @abs(priv.cursor_pos.x - pos.x) < 1 and + @abs(priv.cursor_pos.y - pos.y) < 1; + if (is_cursor_still) return; + + // If we don't have focus, and we want it, grab it. + if (priv.config) |config| { + const gl_area_widget = priv.gl_area.as(gtk.Widget); + if (gl_area_widget.hasFocus() == 0 and + config.get().@"focus-follows-mouse") + { + _ = gl_area_widget.grabFocus(); + } + } + + // Our pos changed, update + priv.cursor_pos = pos; + + // Notify the callback + if (priv.core_surface) |surface| { + const gtk_mods = event.getModifierState(); + const mods = gtk_key.translateMods(gtk_mods); + surface.cursorPosCallback(priv.cursor_pos, mods) catch |err| { + log.warn("error in cursor pos callback err={}", .{err}); + }; + } + } + + fn ecMouseLeave( + ec_motion: *gtk.EventControllerMotion, + self: *Self, + ) callconv(.c) void { + const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return; + + // Get our modifiers + const priv = self.private(); + if (priv.core_surface) |surface| { + // If we have a core surface then we can send the cursor pos + // callback with an invalid position to indicate the mouse left. + const gtk_mods = event.getModifierState(); + const mods = gtk_key.translateMods(gtk_mods); + surface.cursorPosCallback( + .{ .x = -1, .y = -1 }, + mods, + ) catch |err| { + log.warn("error in cursor pos callback err={}", .{err}); + return; + }; + } + } + + fn ecMouseScrollVerticalPrecisionBegin( + _: *gtk.EventControllerScroll, + self: *Self, + ) callconv(.c) void { + self.private().precision_scroll = true; + } + + fn ecMouseScrollVerticalPrecisionEnd( + _: *gtk.EventControllerScroll, + self: *Self, + ) callconv(.c) void { + self.private().precision_scroll = false; + } + + fn ecMouseScrollVertical( + _: *gtk.EventControllerScroll, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) c_int { + const priv: *Private = self.private(); + const surface = priv.core_surface orelse return 0; + + // Multiply precision scrolls by 10 to get a better response from + // touchpad scrolling + const multiplier: f64 = if (priv.precision_scroll) 10.0 else 1.0; + const scroll_mods: input.ScrollMods = .{ + .precision = priv.precision_scroll, + }; + + const scaled = self.scaledCoordinates(x, y); + surface.scrollCallback( + // We invert because we apply natural scrolling to the values. + // This behavior has existed for years without Linux users complaining + // but I suspect we'll have to make this configurable in the future + // or read a system setting. + scaled.x * -1 * multiplier, + scaled.y * -1 * multiplier, + scroll_mods, + ) catch |err| { + log.warn("error in scroll callback err={}", .{err}); + return 0; + }; + + return 1; + } + + fn ecMouseScrollHorizontal( + ec: *gtk.EventControllerScroll, + x: f64, + _: f64, + self: *Self, + ) callconv(.c) c_int { + const priv: *Private = self.private(); + + switch (ec.getUnit()) { + .surface => {}, + .wheel => return @intFromBool(false), + else => return @intFromBool(false), + } + + priv.pending_horizontal_scroll += x; + + if (@abs(priv.pending_horizontal_scroll) < 120) { + if (priv.pending_horizontal_scroll_reset) |v| { + _ = glib.Source.remove(v); + priv.pending_horizontal_scroll_reset = null; + } + priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self); + return @intFromBool(true); + } + + _ = self.as(gtk.Widget).activateAction( + if (priv.pending_horizontal_scroll < 0.0) + "tab.next-page" + else + "tab.previous-page", + null, + ); + + if (priv.pending_horizontal_scroll_reset) |v| { + _ = glib.Source.remove(v); + priv.pending_horizontal_scroll_reset = null; + } + + priv.pending_horizontal_scroll = 0.0; + + return @intFromBool(true); + } + + fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE))); + const priv: *Private = self.private(); + priv.pending_horizontal_scroll = 0.0; + priv.pending_horizontal_scroll_reset = null; + return @intFromBool(glib.SOURCE_REMOVE); + } + + fn imPreeditStart( + _: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + // log.warn("GTKIM: preedit start", .{}); + + // Start our composing state for the input method and reset our + // input buffer to empty. + const priv = self.private(); + priv.im_composing = true; + priv.im_len = 0; + } + + fn imPreeditChanged( + ctx: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // Any preedit change should mark that we're composing. Its possible this + // is false using fcitx5-hangul and typing "dkssud<space>" ("안녕"). The + // second "s" results in a "commit" for "안" which sets composing to false, + // but then immediately sends a preedit change for the next symbol. With + // composing set to false we won't commit this text. Therefore, we must + // ensure it is set here. + priv.im_composing = true; + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + const surface = priv.core_surface orelse return; + + // Get our pre-edit string that we'll use to show the user. + var buf: [*:0]u8 = undefined; + ctx.as(gtk.IMContext).getPreeditString( + &buf, + null, + null, + ); + defer glib.free(buf); + const str = std.mem.sliceTo(buf, 0); + + // Update our preedit state in Ghostty core + // log.warn("GTKIM: preedit change str={s}", .{str}); + surface.preeditCallback(str) catch |err| { + log.warn( + "error in preedit callback err={}", + .{err}, + ); + }; + } + + fn imPreeditEnd( + _: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + // log.warn("GTKIM: preedit end", .{}); + + // End our composing state for GTK, allowing us to commit the text. + const priv = self.private(); + priv.im_composing = false; + + // End our preedit state in Ghostty core + const surface = priv.core_surface orelse return; + surface.preeditCallback(null) catch |err| { + log.warn("error in preedit callback err={}", .{err}); + }; + } + + fn imCommit( + _: *gtk.IMMulticontext, + bytes: [*:0]u8, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const str = std.mem.sliceTo(bytes, 0); + + // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{ + // self.im_composing, + // self.in_keyevent, + // str, + // }); + + // We need to handle commit specially if we're in a key event. + // Specifically, GTK will send us a commit event for basic key + // encodings like "a" (on a US layout keyboard). We don't want + // to treat this as IME committed text because we want to associate + // it with a key event (i.e. "a" key press). + switch (priv.in_keyevent) { + // If we're not in a key event then this commit is from + // some other source (i.e. on-screen keyboard, tablet, etc.) + // and we want to commit the text to the core surface. + .false => {}, + + // If we're in a composing state and in a key event then this + // key event is resulting in a commit of multiple keypresses + // and we don't want to encode it alongside the keypress. + .composing => {}, + + // If we're not composing then this commit is just a normal + // key encoding and we want our key event to handle it so + // that Ghostty can be aware of the key event alongside + // the text. + .not_composing => { + if (str.len > priv.im_buf.len) { + log.warn("not enough buffer space for input method commit", .{}); + return; + } + + // Copy our committed text to the buffer + @memcpy(priv.im_buf[0..str.len], str); + priv.im_len = @intCast(str.len); + + // log.debug("input commit len={}", .{priv.im_len}); + return; + }, + } + + // If we reach this point from above it means we're composing OR + // not in a keypress. In either case, we want to commit the text + // given to us because that's what GTK is asking us to do. If we're + // not in a keypress it means that this commit came via a non-keyboard + // event (i.e. on-screen keyboard, tablet of some kind, etc.). + + // Committing ends composing state + priv.im_composing = false; + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + if (priv.core_surface) |surface| { + // End our preedit state. Well-behaved input methods do this for us + // by triggering a preedit-end event but some do not (ibus 1.5.29). + surface.preeditCallback(null) catch |err| { + log.warn("error in preedit callback err={}", .{err}); + }; + + // Send the text to the core surface, associated with no key (an + // invalid key, which should produce no PTY encoding). + _ = surface.keyCallback(.{ + .action = .press, + .key = .unidentified, + .mods = .{}, + .consumed_mods = .{}, + .composing = false, + .utf8 = str, + }) catch |err| { + log.warn("error in key callback err={}", .{err}); + }; + } + } + + fn glareaRealize( + _: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + log.debug("realize", .{}); + + // Make the GL area current so we can detect any OpenGL errors. If + // we have errors here we can't render and we switch to the error + // state. + const priv = self.private(); + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"}); + log.warn("this error is almost always due to a library, driver, or GTK issue", .{}); + log.warn("this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context", .{}); + self.setError(true); + return; + } + + // If we already have an initialized surface then we notify it. + // If we don't, we'll initialize it on the first resize so we have + // our proper initial dimensions. + if (priv.core_surface) |v| realize: { + v.renderer.displayRealized() catch |err| { + log.warn("core displayRealized failed err={}", .{err}); + break :realize; + }; + + self.redraw(); + } + + // Setup our input method. We do this here because this will + // create a strong reference back to ourself and we want to be + // able to release that in unrealize. + priv.im_context.as(gtk.IMContext).setClientWidget(self.as(gtk.Widget)); + } + + fn glareaUnrealize( + gl_area: *gtk.GLArea, + self: *Self, + ) callconv(.c) void { + log.debug("unrealize", .{}); + + // Notify our core surface + const priv = self.private(); + if (priv.core_surface) |surface| { + // There is no guarantee that our GLArea context is current + // when unrealize is emitted, so we need to make it current. + gl_area.makeCurrent(); + if (gl_area.getError()) |err| { + // I don't know a scenario this can happen, but it means + // we probably leaked memory because displayUnrealized + // below frees resources that aren't specifically OpenGL + // related. I didn't make the OpenGL renderer handle this + // scenario because I don't know if its even possible + // under valid circumstances, so let's log. + log.warn( + "gl_area_make_current failed in unrealize msg={s}", + .{err.f_message orelse "(no message)"}, + ); + log.warn("OpenGL resources and memory likely leaked", .{}); + return; + } + + surface.renderer.displayUnrealized(); + } + + // Unset our input method + priv.im_context.as(gtk.IMContext).setClientWidget(null); + } + + fn glareaRender( + _: *gtk.GLArea, + _: *gdk.GLContext, + self: *Self, + ) callconv(.c) c_int { + // If we don't have a surface then we failed to initialize for + // some reason and there's nothing to draw to the GLArea. + const priv = self.private(); + const surface = priv.core_surface orelse return 1; + + surface.renderer.drawFrame(true) catch |err| { + log.warn("failed to draw frame err={}", .{err}); + return 0; + }; + + return 1; + } + + fn glareaResize( + gl_area: *gtk.GLArea, + width: c_int, + height: c_int, + self: *Self, + ) callconv(.c) void { + // Some debug output to help understand what GTK is telling us. + { + const widget = gl_area.as(gtk.Widget); + const scale_factor = widget.getScaleFactor(); + const window_scale_factor = scale: { + const root = widget.getRoot() orelse break :scale 0; + const gtk_native = root.as(gtk.Native); + const gdk_surface = gtk_native.getSurface() orelse break :scale 0; + break :scale gdk_surface.getScaleFactor(); + }; + + log.debug("gl resize width={} height={} scale={} window_scale={}", .{ + width, + height, + scale_factor, + window_scale_factor, + }); + } + + // Store our cached size + const priv = self.private(); + + const new_size: apprt.SurfaceSize = .{ + .width = @intCast(width), + .height = @intCast(height), + }; + const changed = !priv.size.eql(&new_size); + priv.size = new_size; + + // If our surface is realize, we send callbacks. + if (priv.core_surface) |surface| { + // We also update the content scale because there is no signal for + // content scale change and it seems to trigger a resize event. + surface.contentScaleCallback(self.getContentScale()) catch |err| { + log.warn("error in content scale callback err={}", .{err}); + }; + + if (changed) { + surface.sizeCallback(new_size) catch |err| { + log.warn("error in size callback err={}", .{err}); + }; + // Setup our resize overlay if configured + self.resizeOverlaySchedule(); + } + + return; + } + + // If we don't have a surface, then we initialize it. + self.initSurface() catch |err| { + log.warn("surface failed to initialize err={}", .{err}); + }; + } + + const InitError = Allocator.Error || error{ + GLAreaError, + SurfaceError, + }; + + fn initSurface(self: *Self) InitError!void { + const priv: *Private = self.private(); + assert(priv.core_surface == null); + const gl_area = priv.gl_area; + + // We need to make the context current so we can call GL functions. + // This is required for all surface operations. + gl_area.makeCurrent(); + if (gl_area.getError()) |err| { + log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"}); + log.warn("this error is usually due to a driver or gtk bug", .{}); + log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{}); + return error.GLAreaError; + } + + const app = Application.default(); + const alloc = app.allocator(); + + // Make our pointer to store our surface + const surface = try alloc.create(CoreSurface); + errdefer alloc.destroy(surface); + + // Add ourselves to the list of surfaces on the app. + try app.core().addSurface(self.rt()); + errdefer app.core().deleteSurface(self.rt()); + + // Initialize our surface configuration. + var config = try apprt.surface.newConfig( + app.core(), + priv.config.?.get(), + priv.context, + ); + defer config.deinit(); + + if (priv.overrides.command) |c| { + config.command = try c.clone(config._arena.?.allocator()); + } + if (priv.overrides.working_directory) |wd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } + + // Properties that can impact surface init + if (priv.font_size_request) |size| config.@"font-size" = size.points; + if (priv.pwd) |pwd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } + + // Initialize the surface + surface.init( + alloc, + &config, + app.core(), + app.rt(), + &priv.rt_surface, + ) catch |err| { + log.warn("failed to initialize surface err={}", .{err}); + return error.SurfaceError; + }; + errdefer surface.deinit(); + + // Store it! + priv.core_surface = surface; + + // Emit the signal that we initialized the surface. + Surface.signals.init.impl.emit( + self, + null, + .{}, + null, + ); + + self.updateFocus(priv.focused); + } + + fn resizeOverlaySchedule(self: *Self) void { + const priv = self.private(); + const surface = priv.core_surface orelse return; + + // Only show the resize overlay if its enabled + const config = if (priv.config) |c| c.get() else return; + switch (config.@"resize-overlay") { + .always, .@"after-first" => {}, + .never => return, + } + + // If we have resize overlays enabled, setup an idler + // to show that. We do this in an idle tick because doing it + // during the resize results in flickering. + var buf: [32]u8 = undefined; + priv.resize_overlay.setLabel(text: { + const grid_size = surface.size.grid(); + break :text std.fmt.bufPrintZ( + &buf, + "{d} x {d}", + .{ + grid_size.columns, + grid_size.rows, + }, + ) catch |err| err: { + log.warn("unable to format text: {}", .{err}); + break :err ""; + }; + }); + priv.resize_overlay.schedule(); + } + + fn ecUrlMouseEnter( + _: *gtk.EventControllerMotion, + _: f64, + _: f64, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const right = priv.url_right.as(gtk.Widget); + right.setVisible(1); + } + + fn ecUrlMouseLeave( + _: *gtk.EventControllerMotion, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const right = priv.url_right.as(gtk.Widget); + right.setVisible(0); + } + + fn titleDialogSet( + _: *TitleDialog, + title_ptr: [*:0]const u8, + self: *Self, + ) callconv(.c) void { + const title = std.mem.span(title_ptr); + self.setTitleOverride(if (title.len == 0) null else title); + } + + fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.end_search) catch |err| { + log.warn("unable to perform end_search action err={}", .{err}); + }; + _ = self.private().gl_area.as(gtk.Widget).grabFocus(); + } + + fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| { + log.warn("unable to perform search action err={}", .{err}); + }; + } + + fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| { + log.warn("unable to perform navigate_search action err={}", .{err}); + }; + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(ResizeOverlay); + gobject.ext.ensureType(SearchOverlay); + gobject.ext.ensureType(KeyStateOverlay); + gobject.ext.ensureType(ChildExited); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "surface", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("gl_area", .{}); + class.bindTemplateChildPrivate("url_left", .{}); + class.bindTemplateChildPrivate("url_right", .{}); + class.bindTemplateChildPrivate("child_exited_overlay", .{}); + class.bindTemplateChildPrivate("context_menu", .{}); + class.bindTemplateChildPrivate("error_page", .{}); + class.bindTemplateChildPrivate("progress_bar_overlay", .{}); + class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("search_overlay", .{}); + class.bindTemplateChildPrivate("key_state_overlay", .{}); + class.bindTemplateChildPrivate("terminal_page", .{}); + class.bindTemplateChildPrivate("drop_target", .{}); + class.bindTemplateChildPrivate("im_context", .{}); + + // Template Callbacks + class.bindTemplateCallback("focus_enter", &ecFocusEnter); + class.bindTemplateCallback("focus_leave", &ecFocusLeave); + class.bindTemplateCallback("key_pressed", &ecKeyPressed); + class.bindTemplateCallback("key_released", &ecKeyReleased); + class.bindTemplateCallback("mouse_down", &gcMouseDown); + class.bindTemplateCallback("mouse_up", &gcMouseUp); + class.bindTemplateCallback("mouse_motion", &ecMouseMotion); + class.bindTemplateCallback("mouse_leave", &ecMouseLeave); + class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical); + class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin); + class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd); + class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal); + class.bindTemplateCallback("drop", &dtDrop); + class.bindTemplateCallback("gl_realize", &glareaRealize); + class.bindTemplateCallback("gl_unrealize", &glareaUnrealize); + class.bindTemplateCallback("gl_render", &glareaRender); + class.bindTemplateCallback("gl_resize", &glareaResize); + class.bindTemplateCallback("im_preedit_start", &imPreeditStart); + class.bindTemplateCallback("im_preedit_changed", &imPreeditChanged); + class.bindTemplateCallback("im_preedit_end", &imPreeditEnd); + class.bindTemplateCallback("im_commit", &imCommit); + class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter); + class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave); + class.bindTemplateCallback("child_exited_close", &childExitedClose); + class.bindTemplateCallback("context_menu_closed", &contextMenuClosed); + class.bindTemplateCallback("notify_config", &propConfig); + class.bindTemplateCallback("notify_error", &propError); + class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); + class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); + class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); + class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); + class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); + class.bindTemplateCallback("search_stop", &searchStop); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("search_next_match", &searchNextMatch); + class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"bell-ringing".impl, + properties.config.impl, + properties.@"child-exited".impl, + properties.@"default-size".impl, + properties.@"error".impl, + properties.@"font-size-request".impl, + properties.focused.impl, + properties.@"key-sequence".impl, + properties.@"key-table".impl, + properties.@"min-size".impl, + properties.@"mouse-shape".impl, + properties.@"mouse-hidden".impl, + properties.@"mouse-hover-url".impl, + properties.pwd.impl, + properties.title.impl, + properties.@"title-override".impl, + properties.zoom.impl, + properties.@"is-split".impl, + properties.readonly.impl, + + // For Gtk.Scrollable + properties.hadjustment.impl, + properties.vadjustment.impl, + properties.@"hscroll-policy".impl, + properties.@"vscroll-policy".impl, + }); + + // Signals + signals.bell.impl.register(.{}); + signals.@"close-request".impl.register(.{}); + signals.@"clipboard-read".impl.register(.{}); + signals.@"clipboard-write".impl.register(.{}); + signals.init.impl.register(.{}); + signals.menu.impl.register(.{}); + signals.@"present-request".impl.register(.{}); + signals.@"toggle-fullscreen".impl.register(.{}); + signals.@"toggle-maximize".impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; + + /// Simple dimensions struct for the surface used by various properties. + pub const Size = extern struct { + width: u32, + height: u32, + + pub const getGObjectType = gobject.ext.defineBoxed( + Size, + .{ .name = "GhosttySurfaceSize" }, + ); + }; +}; + +/// The state of the key event while we're doing IM composition. +/// See gtkKeyPressed for detailed descriptions. +pub const IMKeyEvent = enum { + /// Not in a key event. + false, + + /// In a key event but im_composing was either true or false + /// prior to the calling IME processing. This is important to + /// work around different input methods calling commit and + /// preedit end in a different order. + composing, + not_composing, +}; + +fn translateMouseButton(button: c_uint) input.MouseButton { + return switch (button) { + 1 => .left, + 2 => .middle, + 3 => .right, + 4 => .four, + 5 => .five, + 6 => .six, + 7 => .seven, + 8 => .eight, + 9 => .nine, + 10 => .ten, + 11 => .eleven, + else => .unknown, + }; +} + +/// A namespace for our clipboard-related functions so Surface isn't SO large. +const Clipboard = struct { + /// Set the clipboard contents. + pub fn set( + self: *Surface, + clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, + confirm: bool, + ) void { + const priv = self.private(); + + // Grab our plaintext content for use in confirmation dialogs + // and signals. We always expect one to exist. + const text: [:0]const u8 = for (contents) |content| { + if (std.mem.eql(u8, content.mime, "text/plain")) { + break content.data; + } + } else return; + + // If no confirmation is necessary, set the clipboard. + if (!confirm) { + const clipboard = get( + priv.gl_area.as(gtk.Widget), + clipboard_type, + ) orelse return; + + const alloc = Application.default().allocator(); + if (alloc.alloc(*gdk.ContentProvider, contents.len)) |providers| { + // Note: we don't need to unref the individual providers + // because new_union takes ownership of them. + defer alloc.free(providers); + + for (contents, 0..) |content, i| { + const bytes = glib.Bytes.new(content.data.ptr, content.data.len); + defer bytes.unref(); + if (std.mem.eql(u8, content.mime, "text/plain")) { + // Add an explicit UTF-8 encoding parameter to the + // text/plain type. The default charset when there is + // none is ASCII, and lots of things look for UTF-8 + // specifically. + // The specs are not clear about the order here, but + // some clients apparently pick the first match in the + // order we set here then garble up bare 'text/plain' + // with non-ASCII UTF-8 content, so offer UTF-8 first. + // + // Note that under X11, GTK automatically adds the + // UTF8_STRING atom when this is present. + const text_provider_atoms = [_][:0]const u8{ + "text/plain;charset=utf-8", + "text/plain", + }; + var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; + for (text_provider_atoms, 0..) |atom, j| { + const provider = gdk.ContentProvider.newForBytes(atom, bytes); + text_providers[j] = provider; + } + const text_union = gdk.ContentProvider.newUnion( + &text_providers, + text_providers.len, + ); + providers[i] = text_union; + } else { + const provider = gdk.ContentProvider.newForBytes(content.mime, bytes); + providers[i] = provider; + } + } + + const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len); + defer all.unref(); + _ = clipboard.setContent(all); + } else |_| { + // If we fail to alloc, we can at least set the text content. + clipboard.setText(text); + } + + Surface.signals.@"clipboard-write".impl.emit( + self, + null, + .{ clipboard_type, text.ptr }, + null, + ); + + return; + } + + showClipboardConfirmation( + self, + .{ .osc_52_write = clipboard_type }, + text, + ); + } + + /// Request data from the clipboard (read the clipboard). This + /// completes asynchronously and will call the `completeClipboardRequest` + /// core surface API when done. + /// + /// Returns true if the request was started, false if the clipboard + /// doesn't contain text (allowing performable keybinds to pass through). + pub fn request( + self: *Surface, + clipboard_type: apprt.Clipboard, + state: apprt.ClipboardRequest, + ) Allocator.Error!bool { + // Get our requested clipboard + const clipboard = get( + self.private().gl_area.as(gtk.Widget), + clipboard_type, + ) orelse return false; + + // For paste requests, check if clipboard has text format available. + // This is a synchronous check that allows performable keybinds to + // pass through when the clipboard contains non-text content (e.g., images). + if (state == .paste) { + const formats = clipboard.getFormats(); + if (formats.containGtype(gobject.ext.types.string) == 0) { + log.debug("clipboard has no text format, not starting paste request", .{}); + return false; + } + } + + // Allocate our userdata + const alloc = Application.default().allocator(); + const ud = try alloc.create(Request); + errdefer alloc.destroy(ud); + ud.* = .{ + // Important: we ref self here so that we can't free memory + // while we have an outstanding clipboard read. + .self = self.ref(), + .state = state, + }; + errdefer self.unref(); + + // Read + clipboard.readTextAsync( + null, + clipboardReadText, + ud, + ); + + return true; + } + + /// Paste explicit text directly into the surface, regardless of the + /// actual clipboard contents. + pub fn paste( + self: *Surface, + text: [:0]const u8, + ) void { + if (text.len == 0) return; + + const surface = self.private().core_surface orelse return; + surface.completeClipboardRequest( + .paste, + text, + false, + ) catch |err| switch (err) { + error.UnsafePaste, + error.UnauthorizedPaste, + => { + showClipboardConfirmation( + self, + .paste, + text, + ); + return; + }, + + else => { + log.warn( + "failed to complete clipboard request err={}", + .{err}, + ); + return; + }, + }; + } + + /// Get the specific type of clipboard for a widget. + fn get( + widget: *gtk.Widget, + clipboard: apprt.Clipboard, + ) ?*gdk.Clipboard { + return switch (clipboard) { + .standard => widget.getClipboard(), + .selection, .primary => widget.getPrimaryClipboard(), + }; + } + + fn showClipboardConfirmation( + self: *Surface, + req: apprt.ClipboardRequest, + str: [:0]const u8, + ) void { + // Build a text buffer for our contents + const contents_buf: *gtk.TextBuffer = .new(null); + defer contents_buf.unref(); + contents_buf.insertAtCursor(str, @intCast(str.len)); + + // Confirm + const dialog = gobject.ext.newInstance( + ClipboardConfirmationDialog, + .{ + .request = &req, + .@"can-remember" = switch (req) { + .osc_52_read, .osc_52_write => true, + .paste => false, + }, + .@"clipboard-contents" = contents_buf, + }, + ); + + _ = ClipboardConfirmationDialog.signals.confirm.connect( + dialog, + *Surface, + clipboardConfirmationConfirm, + self, + .{}, + ); + _ = ClipboardConfirmationDialog.signals.deny.connect( + dialog, + *Surface, + clipboardConfirmationDeny, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + fn clipboardConfirmationConfirm( + dialog: *ClipboardConfirmationDialog, + remember: bool, + self: *Surface, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.core_surface orelse return; + const req = dialog.getRequest() orelse return; + + // Handle remember + if (remember) switch (req.*) { + .osc_52_read => surface.config.clipboard_read = .allow, + .osc_52_write => surface.config.clipboard_write = .allow, + .paste => {}, + }; + + // Get our text + const text_buf = dialog.getClipboardContents() orelse return; + var text_val = gobject.ext.Value.new(?[:0]const u8); + defer text_val.unset(); + gobject.Object.getProperty( + text_buf.as(gobject.Object), + "text", + &text_val, + ); + const text = gobject.ext.Value.get( + &text_val, + ?[:0]const u8, + ) orelse return; + + surface.completeClipboardRequest( + req.*, + text, + true, + ) catch |err| { + log.warn("failed to complete clipboard request: {}", .{err}); + }; + } + + fn clipboardConfirmationDeny( + dialog: *ClipboardConfirmationDialog, + remember: bool, + self: *Surface, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.core_surface orelse return; + const req = dialog.getRequest() orelse return; + + // Handle remember + if (remember) switch (req.*) { + .osc_52_read => surface.config.clipboard_read = .deny, + .osc_52_write => surface.config.clipboard_write = .deny, + .paste => @panic("paste should not be able to be remembered"), + }; + } + + fn clipboardReadText( + source: ?*gobject.Object, + res: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const clipboard = gobject.ext.cast( + gdk.Clipboard, + source orelse return, + ) orelse return; + const req: *Request = @ptrCast(@alignCast(ud orelse return)); + + const alloc = Application.default().allocator(); + defer alloc.destroy(req); + + const self = req.self; + defer self.unref(); + + var gerr: ?*glib.Error = null; + const cstr_ = clipboard.readTextFinish(res, &gerr); + if (gerr) |err| { + defer err.free(); + log.warn( + "failed to read clipboard err={s}", + .{err.f_message orelse "(no message)"}, + ); + return; + } + const cstr = cstr_ orelse return; + defer glib.free(cstr); + const str = std.mem.sliceTo(cstr, 0); + + const surface = self.private().core_surface orelse return; + surface.completeClipboardRequest( + req.state, + str, + false, + ) catch |err| switch (err) { + error.UnsafePaste, + error.UnauthorizedPaste, + => { + showClipboardConfirmation( + self, + req.state, + str, + ); + return; + }, + + else => { + log.warn( + "failed to complete clipboard request err={}", + .{err}, + ); + return; + }, + }; + + Surface.signals.@"clipboard-read".impl.emit( + self, + null, + .{}, + null, + ); + } + + /// The request we send as userdata to the clipboard read. + const Request = struct { + /// "Self" is reffed so we can't dispose it until the clipboard + /// read is complete. Callers must unref when done. + self: *Surface, + state: apprt.ClipboardRequest, + }; +}; + +/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped +/// to [0, 100]. +fn computeFraction(progress: u8) f64 { + return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0; +} + +test "computeFraction" { + try std.testing.expectEqual(1.0, computeFraction(100)); + try std.testing.expectEqual(1.0, computeFraction(255)); + try std.testing.expectEqual(0.0, computeFraction(0)); + try std.testing.expectEqual(0.5, computeFraction(50)); +} |
