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 | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/apprt')
85 files changed, 27186 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/apprt/action.zig b/vendor/libghostty-vt/src/apprt/action.zig new file mode 100644 index 00000000..f6865af8 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/action.zig @@ -0,0 +1,1007 @@ +const std = @import("std"); +const build_config = @import("../build_config.zig"); +const assert = @import("../quirks.zig").inlineAssert; +const apprt = @import("../apprt.zig"); +const configpkg = @import("../config.zig"); +const input = @import("../input.zig"); +const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); +const CoreSurface = @import("../Surface.zig"); +const lib = @import("../lib/main.zig"); + +/// The target for an action. This is generally the thing that had focus +/// while the action was made but the concept of "focus" is not guaranteed +/// since actions can also be triggered by timers, scripts, etc. +pub const Target = union(Key) { + app, + surface: *CoreSurface, + + // Sync with: ghostty_target_tag_e + pub const Key = enum(c_int) { + app, + surface, + + test "ghostty.h Target.Key" { + try lib.checkGhosttyHEnum(Key, "GHOSTTY_TARGET_"); + } + }; + + // Sync with: ghostty_target_u + pub const CValue = extern union { + app: void, + surface: *apprt.Surface, + }; + + // Sync with: ghostty_target_s + pub const C = extern struct { + key: Key, + value: CValue, + }; + + /// Convert to ghostty_target_s. + pub fn cval(self: Target) C { + return .{ + .key = @as(Key, self), + .value = switch (self) { + .app => .{ .app = {} }, + .surface => |v| .{ .surface = v.rt_surface }, + }, + }; + } +}; + +/// The possible actions an apprt has to react to. Actions are one-way +/// messages that are sent to the app runtime to trigger some behavior. +/// +/// Actions are very often key binding actions but can also be triggered +/// by lifecycle events. For example, the `quit_timer` action is not bindable. +/// +/// Importantly, actions are generally OPTIONAL to implement by an apprt. +/// Required functionality is called directly on the runtime structure so +/// there is a compiler error if an action is not implemented. +pub const Action = union(Key) { + // A GUIDE TO ADDING NEW ACTIONS: + // + // 1. Add the action to the `Key` enum. The order of the enum matters + // because it maps directly to the libghostty C enum. For ABI + // compatibility, new actions should be added to the end of the enum. + // + // 2. Add the action and optional value to the Action union. + // + // 3. If the value type is not void, ensure the value is C ABI + // compatible (extern). If it is not, add a `C` decl to the value + // and a `cval` function to convert to the C ABI compatible value. + // + // 4. Update `include/ghostty.h`: add the new key, value, and union + // entry. If the value type is void then only the key needs to be + // added. Ensure the order matches exactly with the Zig code. + + /// Quit the application. + quit, + + /// Open a new window. The target determines whether properties such + /// as font size should be inherited. + new_window, + + /// Open a new tab. If the target is a surface it should be opened in + /// the same window as the surface. If the target is the app then + /// the tab should be opened in a new window. + new_tab, + + /// Closes the tab belonging to the currently focused split, or all other + /// tabs, depending on the mode. + close_tab: CloseTabMode, + + /// Create a new split. The value determines the location of the split + /// relative to the target. + new_split: SplitDirection, + + /// Close all open windows. + close_all_windows, + + /// Toggle maximized window state. + toggle_maximize, + + /// Toggle fullscreen mode. + toggle_fullscreen: Fullscreen, + + /// Toggle tab overview. + toggle_tab_overview, + + /// Toggle whether window directions are shown. + toggle_window_decorations, + + /// Toggle the quick terminal in or out. + toggle_quick_terminal, + + /// Toggle the command palette. + toggle_command_palette, + + /// Toggle the visibility of all Ghostty terminal windows. + toggle_visibility, + + /// Toggle the window background opacity. This only has an effect + /// if the window started as transparent (non-opaque), and toggles + /// it between fully opaque and the configured background opacity. + toggle_background_opacity, + + /// Moves a tab by a relative offset. + /// + /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 + /// for right). If the new position is out of bounds, it wraps around + /// cyclically within the tab range. + move_tab: MoveTab, + + /// Jump to a specific tab. Must handle the scenario that the tab + /// value is invalid. + goto_tab: GotoTab, + + /// Jump to a specific split. + goto_split: GotoSplit, + + /// Jump to next/previous window. + goto_window: GotoWindow, + + /// Resize the split in the given direction. + resize_split: ResizeSplit, + + /// Equalize all the splits in the target window. + equalize_splits, + + /// Toggle whether a split is zoomed or not. A zoomed split is resized + /// to take up the entire window. + toggle_split_zoom, + + /// Present the target terminal whether its a tab, split, or window. + present_terminal, + + /// Sets a size limit (in pixels) for the target terminal. + size_limit: SizeLimit, + + /// Resets the window size to the default size. See the + /// `reset_window_size` keybinding for more information. + reset_window_size, + + /// Specifies the initial size of the target terminal. + /// + /// This may be sent once during the initialization of a surface + /// (as part of the init call) to indicate the initial size requested + /// for the window if it is not maximized or fullscreen. + /// + /// This may also be sent at any time after the surface is initialized + /// to note the new "default size" of the window. This should in general + /// be ignored, but may be useful if the apprt wants to support + /// a "return to default size" action. + initial_size: InitialSize, + + /// The cell size has changed to the given dimensions in pixels. + cell_size: CellSize, + + /// The scrollbar is updating. + scrollbar: terminal.Scrollbar, + + /// The target should be re-rendered. This usually has a specific + /// surface target but if the app is targeted then all active + /// surfaces should be redrawn. + render, + + /// Control whether the inspector is shown or hidden. + inspector: Inspector, + + /// Show the GTK inspector. + show_gtk_inspector, + + /// The inspector for the given target has changes and should be + /// rendered at the next opportunity. + render_inspector, + + /// Show a desktop notification. + desktop_notification: DesktopNotification, + + /// Set the title of the target to the requested value. + set_title: SetTitle, + + /// Set the tab title override for the target's tab. + set_tab_title: SetTitle, + + /// Set the title of the target to a prompted value. It is up to + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, + + /// The current working directory has changed for the target terminal. + pwd: Pwd, + + /// Set the mouse cursor shape. + mouse_shape: terminal.MouseShape, + + /// Set whether the mouse cursor is visible or not. + mouse_visibility: MouseVisibility, + + /// Called when the mouse is over or recently left a link. + mouse_over_link: MouseOverLink, + + /// The health of the renderer has changed. + renderer_health: renderer.Health, + + /// Open the Ghostty configuration. This is platform-specific about + /// what it means; it can mean opening a dedicated UI or just opening + /// a file in a text editor. + open_config, + + /// Called when there are no more surfaces and the app should quit + /// after the configured delay. + /// + /// Despite the name, this is the notification that libghostty sends + /// when there are no more surfaces regardless of if the configuration + /// wants to quit after close, has any delay set, etc. It's up to the + /// apprt to implement the proper logic based on the config. + /// + /// This can be cancelled by sending another quit_timer action with "stop". + /// Multiple "starts" shouldn't happen and can be ignored or cause a + /// restart it isn't that important. + quit_timer: QuitTimer, + + /// Set the window floating state. A floating window is one that is + /// always on top of other windows even when not focused. + float_window: FloatWindow, + + /// Set the secure input functionality on or off. "Secure input" means + /// that the user is currently at some sort of prompt where they may be + /// entering a password or other sensitive information. This can be used + /// by the app runtime to change the appearance of the cursor, setup + /// system APIs to not log the input, etc. + secure_input: SecureInput, + + /// A sequenced key binding has started, continued, or stopped. + /// The UI should show some indication that the user is in a sequenced + /// key mode because other input may be ignored. + key_sequence: KeySequence, + + /// A key table has been activated or deactivated. + key_table: KeyTable, + + /// A terminal color was changed programmatically through things + /// such as OSC 10/11. + color_change: ColorChange, + + /// A request to reload the configuration. The reload request can be + /// from a user or for some internal reason. The reload request may + /// request it is a soft reload or a full reload. See the struct for + /// more documentation. + /// + /// The configuration should be passed to updateConfig either at the + /// app or surface level depending on the target. + reload_config: ReloadConfig, + + /// The configuration has changed. The value is a pointer to the new + /// configuration. The pointer is only valid for the duration of the + /// action and should not be stored. + /// + /// This should be used by apprts to update any internal state that + /// depends on configuration for the given target (i.e. headerbar colors). + /// The apprt should copy any data it needs since the memory lifetime + /// is only valid for the duration of the action. + /// + /// This allows an apprt to have config-dependent state reactively + /// change without having to store the entire configuration or poll + /// for changes. + config_change: ConfigChange, + + /// Closes the currently focused window. + close_window, + + /// Called when the bell character is seen. The apprt should do whatever + /// it needs to ring the bell. This is usually a sound or visual effect. + ring_bell, + + /// Undo the last action. See the "undo" keybinding for more + /// details on what can and cannot be undone. + undo, + + /// Redo the last undone action. + redo, + + check_for_updates, + + /// Open a URL using the native OS mechanisms. On macOS this might be `open` + /// or on Linux this might be `xdg-open`. The exact mechanism is up to the + /// apprt. + open_url: OpenUrl, + + /// Show a native GUI notification that the child process has exited. + show_child_exited: apprt.surface.Message.ChildExited, + + /// Show a native GUI notification about the progress of some TUI operation. + progress_report: terminal.osc.Command.ProgressReport, + + /// Show the on-screen keyboard. + show_on_screen_keyboard, + + /// A command has finished, + command_finished: CommandFinished, + + /// Start the search overlay with an optional initial needle. If the + /// search is already active and the needle is non-empty, update the + /// current search needle and focus the search input. + start_search: StartSearch, + + /// End the search overlay, clearing the search state and hiding it. + end_search, + + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + + /// The readonly state of the surface has changed. + readonly: Readonly, + + /// Copy the effective title of the surface to the clipboard. + /// The effective title is the user-overridden title if set, + /// otherwise the terminal-set title. + copy_title_to_clipboard, + + /// Sync with: ghostty_action_tag_e + pub const Key = enum(c_int) { + quit, + new_window, + new_tab, + close_tab, + new_split, + close_all_windows, + toggle_maximize, + toggle_fullscreen, + toggle_tab_overview, + toggle_window_decorations, + toggle_quick_terminal, + toggle_command_palette, + toggle_visibility, + toggle_background_opacity, + move_tab, + goto_tab, + goto_split, + goto_window, + resize_split, + equalize_splits, + toggle_split_zoom, + present_terminal, + size_limit, + reset_window_size, + initial_size, + cell_size, + scrollbar, + render, + inspector, + show_gtk_inspector, + render_inspector, + desktop_notification, + set_title, + set_tab_title, + prompt_title, + pwd, + mouse_shape, + mouse_visibility, + mouse_over_link, + renderer_health, + open_config, + quit_timer, + float_window, + secure_input, + key_sequence, + key_table, + color_change, + reload_config, + config_change, + close_window, + ring_bell, + undo, + redo, + check_for_updates, + open_url, + show_child_exited, + progress_report, + show_on_screen_keyboard, + command_finished, + start_search, + end_search, + search_total, + search_selected, + readonly, + copy_title_to_clipboard, + + test "ghostty.h Action.Key" { + try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_"); + } + }; + + /// Sync with: ghostty_action_u + pub const CValue = cvalue: { + const key_fields = @typeInfo(Key).@"enum".fields; + var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined; + for (key_fields, 0..) |field, i| { + const action = @unionInit(Action, field.name, undefined); + const Type = t: { + const Type = @TypeOf(@field(action, field.name)); + // Types can provide custom types for their CValue. + if (Type != void and @hasDecl(Type, "C")) break :t Type.C; + break :t Type; + }; + + union_fields[i] = .{ + .name = field.name, + .type = Type, + .alignment = @alignOf(Type), + }; + } + + break :cvalue @Type(.{ .@"union" = .{ + .layout = .@"extern", + .tag_type = null, + .fields = &union_fields, + .decls = &.{}, + } }); + }; + + /// Sync with: ghostty_action_s + pub const C = extern struct { + key: Key, + value: CValue, + }; + + comptime { + // For ABI compatibility, we expect that this is our union size. + // At the time of writing, we don't promise ABI compatibility + // so we can change this but I want to be aware of it. + assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { + 4 => 16, + 8 => 24, + else => unreachable, + }); + } + + /// Returns the value type for the given key. + pub fn Value(comptime key: Key) type { + inline for (@typeInfo(Action).@"union".fields) |field| { + const field_key = @field(Key, field.name); + if (field_key == key) return field.type; + } + + unreachable; + } + + /// Convert to ghostty_action_s. + pub fn cval(self: Action) C { + const value: CValue = switch (self) { + inline else => |v, tag| @unionInit( + CValue, + @tagName(tag), + if (@TypeOf(v) != void and @hasDecl(@TypeOf(v), "cval")) v.cval() else v, + ), + }; + + return .{ + .key = @as(Key, self), + .value = value, + }; + } +}; + +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const SplitDirection = enum(c_int) { + right, + down, + left, + up, + + test "ghostty.h SplitDirection" { + try lib.checkGhosttyHEnum(SplitDirection, "GHOSTTY_SPLIT_DIRECTION_"); + } +}; + +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoSplit = enum(c_int) { + previous, + next, + + up, + left, + down, + right, + + test "ghostty.h GotoSplit" { + try lib.checkGhosttyHEnum(GotoSplit, "GHOSTTY_GOTO_SPLIT_"); + } +}; + +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, + + test "ghostty.h GotoWindow" { + try lib.checkGhosttyHEnum(GotoWindow, "GHOSTTY_GOTO_WINDOW_"); + } +}; + +/// The amount to resize the split by and the direction to resize it in. +pub const ResizeSplit = extern struct { + amount: u16, + direction: Direction, + + pub const Direction = enum(c_int) { + up, + down, + left, + right, + + test "ghostty.h ResizeSplit.Direction" { + try lib.checkGhosttyHEnum(Direction, "GHOSTTY_RESIZE_SPLIT_"); + } + }; +}; + +pub const MoveTab = extern struct { + amount: isize, +}; + +/// The tab to jump to. This is non-exhaustive so that integer values represent +/// the index (zero-based) of the tab to jump to. Negative values are special +/// values. +pub const GotoTab = enum(c_int) { + previous = -1, + next = -2, + last = -3, + _, + + // TODO: check non-exhaustive enums + // test "ghostty.h GotoTab" { + // try lib.checkGhosttyHEnum(GotoTab, "GHOSTTY_GOTO_TAB_"); + // } +}; + +/// The fullscreen mode to toggle to if we're moving to fullscreen. +pub const Fullscreen = enum(c_int) { + native, + + /// macOS has a non-native fullscreen mode that is more like a maximized + /// window. This is much faster to enter and exit than the native mode. + macos_non_native, + macos_non_native_visible_menu, + macos_non_native_padded_notch, + + test "ghostty.h Fullscreen" { + try lib.checkGhosttyHEnum(Fullscreen, "GHOSTTY_FULLSCREEN_"); + } +}; + +pub const FloatWindow = enum(c_int) { + on, + off, + toggle, + + test "ghostty.h FloatWindow" { + try lib.checkGhosttyHEnum(FloatWindow, "GHOSTTY_FLOAT_WINDOW_"); + } +}; + +pub const SecureInput = enum(c_int) { + on, + off, + toggle, + + test "ghostty.h SecureInput" { + try lib.checkGhosttyHEnum(SecureInput, "GHOSTTY_SECURE_INPUT_"); + } +}; + +/// The inspector mode to toggle to if we're toggling the inspector. +pub const Inspector = enum(c_int) { + toggle, + show, + hide, + + test "ghostty.h Inspector" { + try lib.checkGhosttyHEnum(Inspector, "GHOSTTY_INSPECTOR_"); + } +}; + +pub const QuitTimer = enum(c_int) { + start, + stop, + + test "ghostty.h QuitTimer" { + try lib.checkGhosttyHEnum(QuitTimer, "GHOSTTY_QUIT_TIMER_"); + } +}; + +pub const Readonly = enum(c_int) { + off, + on, + + test "ghostty.h Readonly" { + try lib.checkGhosttyHEnum(Readonly, "GHOSTTY_READONLY_"); + } +}; + +pub const MouseVisibility = enum(c_int) { + visible, + hidden, + + test "ghostty.h MouseVisibility" { + try lib.checkGhosttyHEnum(MouseVisibility, "GHOSTTY_MOUSE_"); + } +}; + +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, + + test "ghostty.h PromptTitle" { + try lib.checkGhosttyHEnum(PromptTitle, "GHOSTTY_PROMPT_TITLE_"); + } +}; + +pub const MouseOverLink = struct { + url: [:0]const u8, + + // Sync with: ghostty_action_mouse_over_link_s + pub const C = extern struct { + url: [*]const u8, + len: usize, + }; + + pub fn cval(self: MouseOverLink) C { + return .{ + .url = self.url.ptr, + .len = self.url.len, + }; + } +}; + +pub const SizeLimit = extern struct { + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, +}; + +pub const InitialSize = extern struct { + width: u32, + height: u32, + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineBoxed( + InitialSize, + .{ .name = "GhosttyApprtInitialSize" }, + ), + + .none => void, + }; +}; + +pub const CellSize = extern struct { + width: u32, + height: u32, +}; + +pub const SetTitle = struct { + title: [:0]const u8, + + // Sync with: ghostty_action_set_title_s + pub const C = extern struct { + title: [*:0]const u8, + }; + + pub fn cval(self: SetTitle) C { + return .{ + .title = self.title.ptr, + }; + } + + pub fn format( + value: @This(), + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: *std.Io.Writer, + ) !void { + try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.title }); + } +}; + +pub const Pwd = struct { + pwd: [:0]const u8, + + // Sync with: ghostty_action_set_pwd_s + pub const C = extern struct { + pwd: [*:0]const u8, + }; + + pub fn cval(self: Pwd) C { + return .{ + .pwd = self.pwd.ptr, + }; + } + + pub fn format( + value: @This(), + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: *std.Io.Writer, + ) !void { + try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.pwd }); + } +}; + +/// The desktop notification to show. +pub const DesktopNotification = struct { + title: [:0]const u8, + body: [:0]const u8, + + // Sync with: ghostty_action_desktop_notification_s + pub const C = extern struct { + title: [*:0]const u8, + body: [*:0]const u8, + }; + + pub fn cval(self: DesktopNotification) C { + return .{ + .title = self.title.ptr, + .body = self.body.ptr, + }; + } + + pub fn format( + value: @This(), + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: *std.Io.Writer, + ) !void { + try writer.print("{s}{{ title: {s}, body: {s} }}", .{ + @typeName(@This()), + value.title, + value.body, + }); + } +}; + +pub const KeySequence = union(enum) { + trigger: input.Trigger, + end, + + // Sync with: ghostty_action_key_sequence_s + pub const C = extern struct { + active: bool, + trigger: input.Trigger.C, + }; + + pub fn cval(self: KeySequence) C { + return switch (self) { + .trigger => |t| .{ .active = true, .trigger = t.cval() }, + .end => .{ .active = false, .trigger = .{} }, + }; + } +}; + +pub const KeyTable = union(enum) { + activate: []const u8, + deactivate, + deactivate_all, + + // Sync with: ghostty_action_key_table_tag_e + pub const Tag = enum(c_int) { + activate, + deactivate, + deactivate_all, + }; + + // Sync with: ghostty_action_key_table_u + pub const CValue = extern union { + activate: extern struct { + name: [*]const u8, + len: usize, + }, + }; + + // Sync with: ghostty_action_key_table_s + pub const C = extern struct { + tag: Tag, + value: CValue, + }; + + pub fn cval(self: KeyTable) C { + return switch (self) { + .activate => |name| .{ + .tag = .activate, + .value = .{ .activate = .{ .name = name.ptr, .len = name.len } }, + }, + .deactivate => .{ + .tag = .deactivate, + .value = undefined, + }, + .deactivate_all => .{ + .tag = .deactivate_all, + .value = undefined, + }, + }; + } +}; + +pub const ColorChange = extern struct { + kind: ColorKind, + r: u8, + g: u8, + b: u8, +}; + +pub const ColorKind = enum(c_int) { + // Negative numbers indicate some named kind + foreground = -1, + background = -2, + cursor = -3, + + // 0+ values indicate a palette index + _, + + // TODO: check non-non-exhaustive enums + // test "ghostty.h ColorKind" { + // try lib.checkGhosttyHEnum(ColorKind, "GHOSTTY_COLOR_KIND_"); + // } +}; + +pub const ReloadConfig = extern struct { + /// A soft reload means that the configuration doesn't need to be + /// read off disk, but libghostty needs the full config again so call + /// updateConfig with it. + soft: bool = false, +}; + +pub const ConfigChange = struct { + config: *const configpkg.Config, + + // Sync with: ghostty_action_config_change_s + pub const C = extern struct { + config: *const configpkg.Config, + }; + + pub fn cval(self: ConfigChange) C { + return .{ + .config = self.config, + }; + } +}; + +/// Open a URL +pub const OpenUrl = struct { + /// The type of data that the URL refers to. + kind: Kind, + + /// The URL. + url: []const u8, + + /// The type of the data at the URL to open. This is used as a hint to + /// potentially open the URL in a different way. + /// + /// Sync with: ghostty_action_open_url_kind_e + pub const Kind = enum(c_int) { + /// The type is unknown. This is the default and apprts should + /// open the URL in the most generic way possible. For example, + /// on macOS this would be the equivalent of `open` or on Linux + /// this would be `xdg-open`. + unknown, + + /// The URL is known to be a text file. In this case, the apprt + /// should try to open the URL in a text editor or viewer or + /// some equivalent, if possible. + text, + + /// The URL is known to contain HTML content. + html, + + test "ghostty.h OpenUrl.Kind" { + try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_"); + } + }; + + // Sync with: ghostty_action_open_url_s + pub const C = extern struct { + kind: Kind, + url: [*]const u8, + len: usize, + }; + + pub fn cval(self: OpenUrl) C { + return .{ + .kind = self.kind, + .url = self.url.ptr, + .len = self.url.len, + }; + } +}; + +/// sync with ghostty_action_close_tab_mode_e in ghostty.h +pub const CloseTabMode = enum(c_int) { + /// Close the current tab. + this, + /// Close all other tabs. + other, + /// Close all tabs to the right of the current tab. + right, + + test "ghostty.h CloseTabMode" { + try lib.checkGhosttyHEnum(CloseTabMode, "GHOSTTY_ACTION_CLOSE_TAB_MODE_"); + } +}; + +pub const CommandFinished = struct { + exit_code: ?u8, + duration: configpkg.Config.Duration, + + /// sync with ghostty_action_command_finished_s in ghostty.h + pub const C = extern struct { + exit_code: i16, + duration: u64, + }; + + pub fn cval(self: CommandFinished) C { + return .{ + .exit_code = self.exit_code orelse -1, + .duration = self.duration.duration, + }; + } +}; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; + +test { + _ = std.testing.refAllDeclsRecursive(@This()); +} diff --git a/vendor/libghostty-vt/src/apprt/browser.zig b/vendor/libghostty-vt/src/apprt/browser.zig new file mode 100644 index 00000000..3b1aa468 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/browser.zig @@ -0,0 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; +pub const App = struct {}; +pub const Window = struct {}; diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig new file mode 100644 index 00000000..519a35f2 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/embedded.zig @@ -0,0 +1,2231 @@ +//! Application runtime for the embedded version of Ghostty. The embedded +//! version is when Ghostty is embedded within a parent host application, +//! rather than owning the application lifecycle itself. This is used for +//! example for the macOS build of Ghostty so that we can use a native +//! Swift+XCode-based application. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const objc = @import("objc"); +const apprt = @import("../apprt.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 CoreApp = @import("../App.zig"); +const CoreInspector = @import("../inspector/main.zig").Inspector; +const CoreSurface = @import("../Surface.zig"); +const configpkg = @import("../config.zig"); +const Config = configpkg.Config; + +const log = std.log.scoped(.embedded_window); + +pub const resourcesDir = internal_os.resourcesDir; + +pub const App = struct { + /// Because we only expect the embedding API to be used in embedded + /// environments, the options are extern so that we can expose it + /// directly to a C callconv and not pay for any translation costs. + /// + /// C type: ghostty_runtime_config_s + pub const Options = extern struct { + /// These are just aliases to make the function signatures below + /// more obvious what values will be sent. + const AppUD = ?*anyopaque; + const SurfaceUD = ?*anyopaque; + + /// Userdata that is passed to all the callbacks. + userdata: AppUD = null, + + /// True if the selection clipboard is supported. + supports_selection_clipboard: bool = false, + + /// Callback called to wakeup the event loop. This should trigger + /// a full tick of the app loop. + wakeup: *const fn (AppUD) callconv(.c) void, + + /// Callback called to handle an action. + action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, + + /// Read the clipboard value. Returns true if the clipboard request + /// was started and complete_clipboard_request may be called with the + /// given state pointer. Returns false if the clipboard request couldn't + /// be started (such as when no text is available for a paste request). + read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, + + /// This may be called after a read clipboard call to request + /// confirmation that the clipboard value is safe to read. The embedder + /// must call complete_clipboard_request with the given request. + confirm_read_clipboard: *const fn ( + SurfaceUD, + [*:0]const u8, + *apprt.ClipboardRequest, + apprt.ClipboardRequestType, + ) callconv(.c) void, + + /// Write the clipboard value. + write_clipboard: *const fn ( + SurfaceUD, + c_int, + [*]const CAPI.ClipboardContent, + usize, + bool, + ) callconv(.c) void, + + /// Close the current surface given by this function. + close_surface: ?*const fn (SurfaceUD, bool) callconv(.c) void = null, + }; + + /// This is the key event sent for ghostty_surface_key and + /// ghostty_app_key. + pub const KeyEvent = struct { + action: input.Action, + mods: input.Mods, + consumed_mods: input.Mods, + keycode: u32, + text: ?[:0]const u8, + unshifted_codepoint: u32, + composing: bool, + + /// Convert a libghostty key event into a core key event. + fn core(self: KeyEvent) ?input.KeyEvent { + const text: []const u8 = if (self.text) |v| v else ""; + const unshifted_codepoint: u21 = std.math.cast( + u21, + self.unshifted_codepoint, + ) orelse 0; + + // We want to get the physical unmapped key to process keybinds. + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == self.keycode) break :keycode entry.key; + } else .unidentified; + + // Build our final key event + return .{ + .action = self.action, + .key = physical_key, + .mods = self.mods, + .consumed_mods = self.consumed_mods, + .composing = self.composing, + .utf8 = text, + .unshifted_codepoint = unshifted_codepoint, + }; + } + }; + + core_app: *CoreApp, + opts: Options, + keymap: input.Keymap, + + /// The configuration for the app. This is owned by this structure. + config: Config, + + pub fn init( + self: *App, + core_app: *CoreApp, + config: *const Config, + opts: Options, + ) !void { + // We have to clone the config. + const alloc = core_app.alloc; + var config_clone = try config.clone(alloc); + errdefer config_clone.deinit(); + + var keymap = try input.Keymap.init(); + errdefer keymap.deinit(); + + self.* = .{ + .core_app = core_app, + .config = config_clone, + .opts = opts, + .keymap = keymap, + }; + } + + pub fn terminate(self: *App) void { + self.keymap.deinit(); + self.config.deinit(); + } + + /// Returns true if there are any global keybinds in the configuration. + pub fn hasGlobalKeybinds(self: *const App) bool { + var it = self.config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.*) { + .leader => {}, + inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true, + } + } + + return false; + } + + /// The target of a key event. This is used to determine some subtly + /// different behavior between app and surface key events. + pub const KeyTarget = union(enum) { + app, + surface: *Surface, + }; + + /// See CoreApp.focusEvent + pub fn focusEvent(self: *App, focused: bool) void { + self.core_app.focusEvent(focused); + } + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // Convert our C key event into a Zig one. + const input_event: input.KeyEvent = event.core() orelse + return false; + + // Invoke the core Ghostty logic to handle this input. + const effect: CoreSurface.InputEffect = switch (target) { + .app => if (self.core_app.keyEvent( + self, + input_event, + )) .consumed else .ignored, + + .surface => |surface| try surface.core_surface.keyCallback( + input_event, + ), + }; + + return switch (effect) { + .closed => true, + .ignored => false, + .consumed => true, + }; + } + + /// This should be called whenever the keyboard layout was changed. + pub fn reloadKeymap(self: *App) !void { + // Reload the keymap + try self.keymap.reload(); + } + + /// Loads the keyboard layout. + /// + /// Kind of expensive so this should be avoided if possible. When I say + /// "kind of expensive" I mean that its not something you probably want + /// to run on every keypress. + pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + // We only support keyboard layout detection on macOS. + if (comptime builtin.os.tag != .macos) return .unknown; + + // Any layout larger than this is not something we can handle. + var buf: [256]u8 = undefined; + const id = self.keymap.sourceId(&buf) catch |err| { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + return .unknown; + }; + + return input.KeyboardLayout.mapAppleId(id) orelse .unknown; + } + + pub fn wakeup(self: *const App) void { + self.opts.wakeup(self.opts.userdata); + } + + pub fn wait(self: *const App) !void { + _ = self; + } + + /// Create a new surface for the app. + fn newSurface(self: *App, opts: Surface.Options) !*Surface { + // Grab a surface allocation because we're going to need it. + var surface = try self.core_app.alloc.create(Surface); + errdefer self.core_app.alloc.destroy(surface); + + // Create the surface + try surface.init(self, opts); + errdefer surface.deinit(); + + return surface; + } + + /// Close the given surface. + pub fn closeSurface(self: *App, surface: *Surface) void { + surface.deinit(); + self.core_app.alloc.destroy(surface); + } + + pub fn redrawInspector(self: *App, surface: *Surface) void { + _ = self; + surface.queueInspectorRender(); + } + + /// Perform a given action. Returns `true` if the action was able to be + /// performed, `false` otherwise. + pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) !bool { + // Special case certain actions before they are sent to the + // embedded apprt. + self.performPreAction(target, action, value); + + log.debug("dispatching action target={t} action={} value={any}", .{ + target, + action, + value, + }); + return self.opts.action( + self, + target.cval(), + @unionInit(apprt.Action, @tagName(action), value).cval(), + ); + } + + fn performPreAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) void { + // Special case certain actions before they are sent to the embedder + switch (action) { + .set_title => switch (target) { + .app => {}, + .surface => |surface| { + // Dupe the title so that we can store it. If we get an allocation + // error we just ignore it, since this only breaks a few minor things. + const alloc = self.core_app.alloc; + if (surface.rt_surface.title) |v| alloc.free(v); + surface.rt_surface.title = alloc.dupeZ(u8, value.title) catch null; + }, + }, + + .config_change => switch (target) { + .surface => {}, + + // For app updates, we update our core config. We need to + // clone it because the caller owns the param. + .app => if (value.config.clone(self.core_app.alloc)) |config| { + self.config.deinit(); + self.config = config; + } else |err| { + log.err("error updating app config err={}", .{err}); + }, + }, + + else => {}, + } + } + + /// Send the given IPC to a running Ghostty. Returns `true` if the action was + /// able to be performed, `false` otherwise. + /// + /// Note that this is a static function. Since this is called from a CLI app (or + /// some other process that is not Ghostty) there is no full-featured apprt App + /// to use. + pub fn performIpc( + _: Allocator, + _: apprt.ipc.Target, + comptime action: apprt.ipc.Action.Key, + _: apprt.ipc.Action.Value(action), + ) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { + switch (action) { + .new_window => return false, + } + } +}; + +/// Platform-specific configuration for libghostty. +pub const Platform = union(PlatformTag) { + macos: MacOS, + ios: IOS, + + // If our build target for libghostty is not darwin then we do + // not include macos support at all. + pub const MacOS = if (builtin.target.os.tag.isDarwin()) struct { + /// The view to render the surface on. + nsview: objc.Object, + } else void; + + pub const IOS = if (builtin.target.os.tag.isDarwin()) struct { + /// The view to render the surface on. + uiview: objc.Object, + } else void; + + // The C ABI compatible version of this union. The tag is expected + // to be stored elsewhere. + pub const C = extern union { + macos: extern struct { + nsview: ?*anyopaque, + }, + + ios: extern struct { + uiview: ?*anyopaque, + }, + }; + + /// Initialize a Platform a tag and configuration from the C ABI. + pub fn init(tag_int: c_int, c_platform: C) !Platform { + const tag = try std.meta.intToEnum(PlatformTag, tag_int); + return switch (tag) { + .macos => if (MacOS != void) macos: { + const config = c_platform.macos; + const nsview = objc.Object.fromId(config.nsview orelse + break :macos error.NSViewMustBeSet); + break :macos .{ .macos = .{ .nsview = nsview } }; + } else error.UnsupportedPlatform, + + .ios => if (IOS != void) ios: { + const config = c_platform.ios; + const uiview = objc.Object.fromId(config.uiview orelse + break :ios error.UIViewMustBeSet); + break :ios .{ .ios = .{ .uiview = uiview } }; + } else error.UnsupportedPlatform, + }; + } +}; + +pub const PlatformTag = enum(c_int) { + // "0" is reserved for invalid so we can detect unset values + // from the C API. + + macos = 1, + ios = 2, +}; + +pub const EnvVar = extern struct { + /// The name of the environment variable. + key: [*:0]const u8, + + /// The value of the environment variable. + value: [*:0]const u8, +}; + +pub const Surface = struct { + app: *App, + platform: Platform, + userdata: ?*anyopaque = null, + core_surface: CoreSurface, + content_scale: apprt.ContentScale, + size: apprt.SurfaceSize, + cursor_pos: apprt.CursorPos, + inspector: ?*Inspector = null, + + /// The current title of the surface. The embedded apprt saves this so + /// that getTitle works without the implementer needing to save it. + title: ?[:0]const u8 = null, + + /// Surface initialization options. + pub const Options = extern struct { + /// The platform that this surface is being initialized for and + /// the associated platform-specific configuration. + platform_tag: c_int = 0, + platform: Platform.C = undefined, + + /// Userdata passed to some of the callbacks. + userdata: ?*anyopaque = null, + + /// The scale factor of the screen. + scale_factor: f64 = 1, + + /// The font size to inherit. If 0, default font size will be used. + font_size: f32 = 0, + + /// The working directory to load into. + working_directory: ?[*:0]const u8 = null, + + /// The command to run in the new surface. If this is set then + /// the "wait-after-command" option is also automatically set to true, + /// since this is used for scripting. + /// + /// This command always run in a shell (e.g. via `/bin/sh -c`), + /// despite Ghostty allowing directly executed commands via config. + /// This is a legacy thing and we should probably change it in the + /// future once we have a concrete use case. + command: ?[*:0]const u8 = null, + + /// Extra environment variables to set for the surface. + env_vars: ?[*]EnvVar = null, + env_var_count: usize = 0, + + /// Input to send to the command after it is started. + initial_input: ?[*:0]const u8 = null, + + /// Wait after the command exits + wait_after_command: bool = false, + + /// Context for the new surface + context: apprt.surface.NewSurfaceContext = .window, + }; + + pub fn init(self: *Surface, app: *App, opts: Options) !void { + self.* = .{ + .app = app, + .platform = try .init(opts.platform_tag, opts.platform), + .userdata = opts.userdata, + .core_surface = undefined, + .content_scale = .{ + .x = @floatCast(opts.scale_factor), + .y = @floatCast(opts.scale_factor), + }, + .size = .{ .width = 800, .height = 600 }, + .cursor_pos = .{ .x = -1, .y = -1 }, + }; + + // Add ourselves to the list of surfaces on the app. + try app.core_app.addSurface(self); + errdefer app.core_app.deleteSurface(self); + + // Shallow copy the config so that we can modify it. + var config = try apprt.surface.newConfig(app.core_app, &app.config, opts.context); + defer config.deinit(); + + // If we have a working directory from the options then we set it. + if (opts.working_directory) |c_wd| { + const wd = std.mem.sliceTo(c_wd, 0); + if (wd.len > 0) wd: { + var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { + log.warn( + "error opening requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + defer dir.close(); + + const stat = dir.stat() catch |err| { + log.warn( + "failed to stat requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + + if (stat.kind != .directory) { + log.warn( + "requested working directory is not a directory dir={s}", + .{wd}, + ); + break :wd; + } + + var wd_val: configpkg.WorkingDirectory = .{ .path = wd }; + if (wd_val.finalize(config.arenaAlloc())) |_| { + config.@"working-directory" = wd_val; + } else |err| { + log.warn( + "error finalizing working directory config dir={s} err={}", + .{ wd_val.path, err }, + ); + } + } + } + + // If we have a command from the options then we set it. + if (opts.command) |c_command| { + const cmd = std.mem.sliceTo(c_command, 0); + if (cmd.len > 0) { + config.command = .{ .shell = cmd }; + config.@"wait-after-command" = true; + } + } + + // Apply any environment variables that were requested. + if (opts.env_var_count > 0) { + const alloc = config.arenaAlloc(); + for (opts.env_vars.?[0..opts.env_var_count]) |env_var| { + const key = std.mem.sliceTo(env_var.key, 0); + const value = std.mem.sliceTo(env_var.value, 0); + try config.env.map.put( + alloc, + try alloc.dupeZ(u8, key), + try alloc.dupeZ(u8, value), + ); + } + } + + // If we have an initial input then we set it. + if (opts.initial_input) |c_input| { + const alloc = config.arenaAlloc(); + + // We need to escape the string because the "raw" field + // expects a Zig string. + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try std.zig.stringEscape( + std.mem.sliceTo(c_input, 0), + &buf.writer, + ); + + config.input.list.clearRetainingCapacity(); + try config.input.list.append( + alloc, + .{ .raw = try buf.toOwnedSliceSentinel(0) }, + ); + } + + // Wait after command + if (opts.wait_after_command) { + config.@"wait-after-command" = true; + } + + // Initialize our surface right away. We're given a view that is + // ready to use. + try self.core_surface.init( + app.core_app.alloc, + &config, + app.core_app, + app, + self, + ); + errdefer self.core_surface.deinit(); + + // If our options requested a specific font-size, set that. + if (opts.font_size != 0) { + var font_size = self.core_surface.font_size; + font_size.points = opts.font_size; + try self.core_surface.setFontSize(font_size); + } + } + + pub fn deinit(self: *Surface) void { + // Shut down our inspector + self.freeInspector(); + + // Free our title + if (self.title) |v| self.app.core_app.alloc.free(v); + + // Remove ourselves from the list of known surfaces in the app. + self.app.core_app.deleteSurface(self); + + // Clean up our core surface so that all the rendering and IO stop. + self.core_surface.deinit(); + } + + /// Initialize the inspector instance. A surface can only have one + /// inspector at any given time, so this will return the previous inspector + /// if it was already initialized. + pub fn initInspector(self: *Surface) !*Inspector { + if (self.inspector) |v| return v; + + const alloc = self.app.core_app.alloc; + const inspector = try alloc.create(Inspector); + errdefer alloc.destroy(inspector); + inspector.* = try .init(self); + self.inspector = inspector; + return inspector; + } + + pub fn freeInspector(self: *Surface) void { + if (self.inspector) |v| { + v.deinit(); + self.app.core_app.alloc.destroy(v); + self.inspector = null; + } + } + + pub fn core(self: *Surface) *CoreSurface { + return &self.core_surface; + } + + pub fn rtApp(self: *const Surface) *App { + return self.app; + } + + pub fn close(self: *const Surface, process_alive: bool) void { + const func = self.app.opts.close_surface orelse { + log.info("runtime embedder does not support closing a surface", .{}); + return; + }; + + func(self.userdata, process_alive); + } + + pub fn getContentScale(self: *const Surface) !apprt.ContentScale { + return self.content_scale; + } + + pub fn getSize(self: *const Surface) !apprt.SurfaceSize { + return self.size; + } + + pub fn getTitle(self: *Surface) ?[:0]const u8 { + return self.title; + } + + pub fn supportsClipboard( + self: *const Surface, + clipboard_type: apprt.Clipboard, + ) bool { + return switch (clipboard_type) { + .standard => true, + .selection, .primary => self.app.opts.supports_selection_clipboard, + }; + } + + pub fn clipboardRequest( + self: *Surface, + clipboard_type: apprt.Clipboard, + state: apprt.ClipboardRequest, + ) !bool { + // We need to allocate to get a pointer to store our clipboard request + // so that it is stable until the read_clipboard callback and call + // complete_clipboard_request. This sucks but clipboard requests aren't + // high throughput so it's probably fine. + const alloc = self.app.core_app.alloc; + const state_ptr = try alloc.create(apprt.ClipboardRequest); + errdefer alloc.destroy(state_ptr); + state_ptr.* = state; + + const started = self.app.opts.read_clipboard( + self.userdata, + @intCast(@intFromEnum(clipboard_type)), + state_ptr, + ); + if (!started) { + alloc.destroy(state_ptr); + return false; + } + + return true; + } + + fn completeClipboardRequest( + self: *Surface, + str: [:0]const u8, + state: *apprt.ClipboardRequest, + confirmed: bool, + ) void { + const alloc = self.app.core_app.alloc; + + // Attempt to complete the request, but we may request + // confirmation. + self.core_surface.completeClipboardRequest( + state.*, + str, + confirmed, + ) catch |err| switch (err) { + error.UnsafePaste, + error.UnauthorizedPaste, + => { + self.app.opts.confirm_read_clipboard( + self.userdata, + str.ptr, + state, + state.*, + ); + + return; + }, + + else => log.err("error completing clipboard request err={}", .{err}), + }; + + // We don't defer this because the clipboard confirmation route + // preserves the clipboard request. + alloc.destroy(state); + } + + pub fn setClipboard( + self: *const Surface, + clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, + confirm: bool, + ) !void { + const alloc = self.app.core_app.alloc; + const array = try alloc.alloc(CAPI.ClipboardContent, contents.len); + defer alloc.free(array); + for (contents, 0..) |content, i| { + array[i] = .{ + .mime = content.mime, + .data = content.data, + }; + } + + self.app.opts.write_clipboard( + self.userdata, + @intCast(@intFromEnum(clipboard_type)), + array.ptr, + array.len, + confirm, + ); + } + + pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { + return self.cursor_pos; + } + + pub fn refresh(self: *Surface) void { + self.core_surface.refreshCallback() catch |err| { + log.err("error in refresh callback err={}", .{err}); + return; + }; + } + + pub fn draw(self: *Surface) void { + self.core_surface.draw() catch |err| { + log.err("error in draw err={}", .{err}); + return; + }; + } + + pub fn updateContentScale(self: *Surface, x: f64, y: f64) void { + // We are an embedded API so the caller can send us all sorts of + // garbage. We want to make sure that the float values are valid + // and we don't want to support fractional scaling below 1. + const x_scaled = @max(1, if (std.math.isNan(x)) 1 else x); + const y_scaled = @max(1, if (std.math.isNan(y)) 1 else y); + + self.content_scale = .{ + .x = @floatCast(x_scaled), + .y = @floatCast(y_scaled), + }; + + self.core_surface.contentScaleCallback(self.content_scale) catch |err| { + log.err("error in content scale callback err={}", .{err}); + return; + }; + } + + pub fn updateSize(self: *Surface, width: u32, height: u32) void { + // Runtimes sometimes generate superfluous resize events even + // if the size did not actually change (SwiftUI). We check + // that the size actually changed from what we last recorded + // since resizes are expensive. + if (self.size.width == width and self.size.height == height) return; + + self.size = .{ + .width = width, + .height = height, + }; + + // Call the primary callback. + self.core_surface.sizeCallback(self.size) catch |err| { + log.err("error in size callback err={}", .{err}); + return; + }; + } + + pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) void { + self.core_surface.colorSchemeCallback(scheme) catch |err| { + log.err("error setting color scheme err={}", .{err}); + return; + }; + } + + pub fn mouseButtonCallback( + self: *Surface, + action: input.MouseButtonState, + button: input.MouseButton, + mods: input.Mods, + ) bool { + return self.core_surface.mouseButtonCallback(action, button, mods) catch |err| { + log.err("error in mouse button callback err={}", .{err}); + return false; + }; + } + + pub fn mousePressureCallback( + self: *Surface, + stage: input.MousePressureStage, + pressure: f64, + ) void { + self.core_surface.mousePressureCallback(stage, pressure) catch |err| { + log.err("error in mouse pressure callback err={}", .{err}); + return; + }; + } + + pub fn scrollCallback( + self: *Surface, + xoff: f64, + yoff: f64, + mods: input.ScrollMods, + ) void { + self.core_surface.scrollCallback(xoff, yoff, mods) catch |err| { + log.err("error in scroll callback err={}", .{err}); + return; + }; + } + + pub fn cursorPosCallback( + self: *Surface, + x: f64, + y: f64, + mods: input.Mods, + ) void { + // Convert our unscaled x/y to scaled. + const pos = self.cursorPosToPixels(.{ + .x = @floatCast(x), + .y = @floatCast(y), + }) catch |err| { + log.err( + "error converting cursor pos to scaled pixels in cursor pos callback err={}", + .{err}, + ); + return; + }; + + // There are cases where the platform reports a mouse motion event + // without the cursor actually moving. For example, on macOS, updating + // the window title can trigger a phantom mouse-move event at the same + // coordinates. This can cause the mouse to incorrectly unhide when + // mouse-hide-while-typing is enabled (commonly seen with TUI apps + // like Zellij that frequently update the title). To prevent incorrect + // behavior, we only continue with callback logic if the cursor has + // actually moved. + if (@abs(self.cursor_pos.x - pos.x) < 1 and + @abs(self.cursor_pos.y - pos.y) < 1) return; + + self.cursor_pos = pos; + + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { + log.err("error in cursor pos callback err={}", .{err}); + return; + }; + } + + pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) void { + _ = self.core_surface.preeditCallback(preedit_) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return; + }; + } + + pub fn textCallback(self: *Surface, text: []const u8) void { + _ = self.core_surface.textCallback(text) catch |err| { + log.err("error in key callback err={}", .{err}); + return; + }; + } + + pub fn focusCallback(self: *Surface, focused: bool) void { + self.core_surface.focusCallback(focused) catch |err| { + log.err("error in focus callback err={}", .{err}); + return; + }; + } + + pub fn occlusionCallback(self: *Surface, visible: bool) void { + self.core_surface.occlusionCallback(visible) catch |err| { + log.err("error in occlusion callback err={}", .{err}); + return; + }; + } + + fn queueInspectorRender(self: *Surface) void { + _ = self.app.performAction( + .{ .surface = &self.core_surface }, + .render_inspector, + {}, + ) catch |err| { + log.err("error rendering the inspector err={}", .{err}); + return; + }; + } + + pub fn newSurfaceOptions(self: *const Surface, context: apprt.surface.NewSurfaceContext) apprt.Surface.Options { + const font_size: f32 = font_size: { + if (!self.app.config.@"window-inherit-font-size") break :font_size 0; + break :font_size self.core_surface.font_size.points; + }; + + const working_directory: ?[*:0]const u8 = wd: { + if (!apprt.surface.shouldInheritWorkingDirectory(context, &self.app.config)) break :wd null; + const cwd = self.core_surface.pwd(self.app.core_app.alloc) catch null orelse break :wd null; + defer self.app.core_app.alloc.free(cwd); + break :wd self.app.core_app.alloc.dupeZ(u8, cwd) catch null; + }; + + return .{ + .font_size = font_size, + .working_directory = working_directory, + .context = context, + }; + } + + pub fn defaultTermioEnv(self: *const Surface) !std.process.EnvMap { + const alloc = self.app.core_app.alloc; + var env = try internal_os.getEnvMap(alloc); + errdefer env.deinit(); + + if (comptime builtin.target.os.tag.isDarwin()) { + if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { + env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + env.remove("__XPC_DYLD_LIBRARY_PATH"); + env.remove("DYLD_FRAMEWORK_PATH"); + env.remove("DYLD_INSERT_LIBRARIES"); + env.remove("DYLD_LIBRARY_PATH"); + env.remove("LD_LIBRARY_PATH"); + env.remove("SECURITYSESSIONID"); + env.remove("XPC_SERVICE_NAME"); + } + + // Remove this so that running `ghostty` within Ghostty works. + env.remove("GHOSTTY_MAC_LAUNCH_SOURCE"); + + // If we were launched from the desktop then we want to + // remove the LANGUAGE env var so that we don't inherit + // our translation settings for Ghostty. If we aren't from + // the desktop then we didn't set our LANGUAGE var so we + // don't need to remove it. + if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); + } + + return env; + } + + /// The cursor position from the host directly is in screen coordinates but + /// all our interface works in pixels. + fn cursorPosToPixels(self: *const Surface, pos: apprt.CursorPos) !apprt.CursorPos { + const scale = try self.getContentScale(); + return .{ .x = pos.x * scale.x, .y = pos.y * scale.y }; + } +}; + +/// Inspector is the state required for the terminal inspector. A terminal +/// inspector is 1:1 with a Surface. +pub const Inspector = struct { + const cimgui = @import("dcimgui"); + + surface: *Surface, + ig_ctx: *cimgui.c.ImGuiContext, + backend: ?Backend = null, + content_scale: f64 = 1, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + const Backend = enum { + metal, + + pub fn deinit(self: Backend) void { + switch (self) { + .metal => if (builtin.target.os.tag.isDarwin()) cimgui.ImGui_ImplMetal_Shutdown(), + } + } + }; + + pub fn init(surface: *Surface) !Inspector { + const ig_ctx = cimgui.c.ImGui_CreateContext(null) orelse return error.OutOfMemory; + errdefer cimgui.c.ImGui_DestroyContext(ig_ctx); + cimgui.c.ImGui_SetCurrentContext(ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + io.BackendPlatformName = "ghostty_embedded"; + + // Setup our core inspector + CoreInspector.setup(); + surface.core_surface.activateInspector() catch |err| { + log.err("failed to activate inspector err={}", .{err}); + }; + + return .{ + .surface = surface, + .ig_ctx = ig_ctx, + }; + } + + pub fn deinit(self: *Inspector) void { + self.surface.core_surface.deactivateInspector(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + if (self.backend) |v| v.deinit(); + cimgui.c.ImGui_DestroyContext(self.ig_ctx); + } + + /// Queue a render for the next frame. + pub fn queueRender(self: *Inspector) void { + self.surface.queueInspectorRender(); + } + + /// Initialize the inspector for a metal backend. + pub fn initMetal(self: *Inspector, device: objc.Object) bool { + defer device.msgSend(void, objc.sel("release"), .{}); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + + if (self.backend) |v| { + v.deinit(); + self.backend = null; + } + + if (!cimgui.ImGui_ImplMetal_Init(device.value)) { + log.warn("failed to initialize metal backend", .{}); + return false; + } + self.backend = .metal; + + log.debug("initialized metal backend", .{}); + return true; + } + + pub fn renderMetal( + self: *Inspector, + command_buffer: objc.Object, + desc: objc.Object, + ) !void { + defer { + command_buffer.msgSend(void, objc.sel("release"), .{}); + desc.msgSend(void, objc.sel("release"), .{}); + } + assert(self.backend == .metal); + //log.debug("render", .{}); + + // Setup our imgui frame. We need to render multiple frames to ensure + // ImGui completes all its state processing. I don't know how to fix + // this. + for (0..2) |_| { + cimgui.ImGui_ImplMetal_NewFrame(desc.value); + try self.newFrame(); + cimgui.c.ImGui_NewFrame(); + + // Build our UI + render: { + const surface = &self.surface.core_surface; + const inspector = surface.inspector orelse break :render; + inspector.render(surface); + } + + // Render + cimgui.c.ImGui_Render(); + } + + // MTLRenderCommandEncoder + const encoder = command_buffer.msgSend( + objc.Object, + objc.sel("renderCommandEncoderWithDescriptor:"), + .{desc.value}, + ); + defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); + cimgui.ImGui_ImplMetal_RenderDrawData( + cimgui.c.ImGui_GetDrawData(), + command_buffer.value, + encoder.value, + ); + } + + pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void { + _ = y; + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + + // Cache our scale because we use it for cursor position calculations. + self.content_scale = x; + + // Setup a new style and scale it appropriately. We must use the + // ImGuiStyle constructor to get proper default values (e.g., + // CurveTessellationTol) rather than zero-initialized values. + var style: cimgui.c.ImGuiStyle = undefined; + cimgui.ext.ImGuiStyle_ImGuiStyle(&style); + cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x)); + const active_style = cimgui.c.ImGui_GetStyle(); + active_style.* = style; + } + + pub fn updateSize(self: *Inspector, width: u32, height: u32) void { + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; + } + + pub fn mouseButtonCallback( + self: *Inspector, + action: input.MouseButtonState, + button: input.MouseButton, + mods: input.Mods, + ) void { + _ = mods; + + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + const imgui_button = switch (button) { + .left => cimgui.c.ImGuiMouseButton_Left, + .middle => cimgui.c.ImGuiMouseButton_Middle, + .right => cimgui.c.ImGuiMouseButton_Right, + else => return, // unsupported + }; + + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, imgui_button, action == .press); + } + + pub fn scrollCallback( + self: *Inspector, + xoff: f64, + yoff: f64, + mods: input.ScrollMods, + ) void { + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + // For precision scrolling (trackpads), the values are in pixels which + // scroll way too fast. Scale them down to approximate discrete wheel + // notches. imgui expects 1.0 to scroll ~5 lines of text. + const scale: f64 = if (mods.precision) 0.1 else 1.0; + cimgui.c.ImGuiIO_AddMouseWheelEvent( + io, + @floatCast(xoff * scale), + @floatCast(yoff * scale), + ); + } + + pub fn cursorPosCallback(self: *Inspector, x: f64, y: f64) void { + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddMousePosEvent( + io, + @floatCast(x * self.content_scale), + @floatCast(y * self.content_scale), + ); + } + + pub fn focusCallback(self: *Inspector, focused: bool) void { + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, focused); + } + + pub fn textCallback(self: *Inspector, text: [:0]const u8) void { + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, text.ptr); + } + + pub fn keyCallback( + self: *Inspector, + action: input.Action, + key: input.Key, + mods: input.Mods, + ) !void { + self.queueRender(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + // Update all our modifiers + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); + + // Send our keypress + if (key.imguiKey()) |imgui_key| { + cimgui.c.ImGuiIO_AddKeyEvent( + io, + imgui_key, + action == .press or action == .repeat, + ); + } + } + + fn newFrame(self: *Inspector) !void { + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + // Determine our delta time + const now = try std.time.Instant.now(); + io.DeltaTime = if (self.instant) |prev| delta: { + const since_ns: f64 = @floatFromInt(now.since(prev)); + const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s); + const since_s: f32 = @floatCast(since_ns / ns_per_s); + break :delta @max(0.00001, since_s); + } else (1.0 / 60.0); + self.instant = now; + } +}; + +// C API +pub const CAPI = struct { + const global = &@import("../global.zig").state; + + /// This is the same as Surface.KeyEvent but this is the raw C API version. + const KeyEvent = extern struct { + action: input.Action, + mods: c_int, + consumed_mods: c_int, + keycode: u32, + text: ?[*:0]const u8, + unshifted_codepoint: u32, + composing: bool, + + /// Convert to Zig key event. + fn keyEvent(self: KeyEvent) App.KeyEvent { + return .{ + .action = self.action, + .mods = @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(self.mods))), + )), + .consumed_mods = @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(self.consumed_mods))), + )), + .keycode = self.keycode, + .text = if (self.text) |ptr| std.mem.sliceTo(ptr, 0) else null, + .unshifted_codepoint = self.unshifted_codepoint, + .composing = self.composing, + }; + } + }; + + const SurfaceSize = extern struct { + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, + cell_width_px: u32, + cell_height_px: u32, + }; + + // ghostty_clipboard_content_s + const ClipboardContent = extern struct { + mime: [*:0]const u8, + data: [*:0]const u8, + }; + + // ghostty_text_s + const Text = extern struct { + tl_px_x: f64, + tl_px_y: f64, + offset_start: u32, + offset_len: u32, + text: ?[*:0]const u8, + text_len: usize, + + pub fn deinit(self: *Text) void { + if (self.text) |ptr| { + global.alloc.free(ptr[0..self.text_len :0]); + } + } + }; + + // ghostty_point_s + const Point = extern struct { + tag: Tag, + coord_tag: CoordTag, + x: u32, + y: u32, + + const Tag = enum(c_int) { + active = 0, + viewport = 1, + screen = 2, + history = 3, + }; + + const CoordTag = enum(c_int) { + exact = 0, + top_left = 1, + bottom_right = 2, + }; + + fn pin( + self: Point, + screen: *const terminal.Screen, + ) ?terminal.Pin { + // The core point tag. + const tag: terminal.point.Tag = switch (self.tag) { + inline else => |tag| @field( + terminal.point.Tag, + @tagName(tag), + ), + }; + + // Clamp our point to the screen bounds. + const clamped_x = @min(self.x, screen.pages.cols -| 1); + const clamped_y = @min(self.y, screen.pages.rows -| 1); + + return switch (self.coord_tag) { + // Exact coordinates require a specific pin. + .exact => exact: { + const pt_x = std.math.cast( + terminal.size.CellCountInt, + clamped_x, + ) orelse std.math.maxInt(terminal.size.CellCountInt); + + const pt: terminal.Point = switch (tag) { + inline else => |v| @unionInit( + terminal.Point, + @tagName(v), + .{ .x = pt_x, .y = clamped_y }, + ), + }; + + break :exact screen.pages.pin(pt) orelse null; + }, + + .top_left => screen.pages.getTopLeft(tag), + + .bottom_right => screen.pages.getBottomRight(tag), + }; + } + }; + + // ghostty_selection_s + const Selection = extern struct { + tl: Point, + br: Point, + rectangle: bool, + + fn core( + self: Selection, + screen: *const terminal.Screen, + ) ?terminal.Selection { + return .{ + .bounds = .{ .untracked = .{ + .start = self.tl.pin(screen) orelse return null, + .end = self.br.pin(screen) orelse return null, + } }, + .rectangle = self.rectangle, + }; + } + }; + + // Reference the conditional exports based on target platform + // so they're included in the C API. + comptime { + if (builtin.target.os.tag.isDarwin()) { + _ = Darwin; + } + } + + /// Create a new app. + export fn ghostty_app_new( + opts: *const apprt.runtime.App.Options, + config: *const Config, + ) ?*App { + return app_new_(opts, config) catch |err| { + log.err("error initializing app err={}", .{err}); + return null; + }; + } + + fn app_new_( + opts: *const apprt.runtime.App.Options, + config: *const Config, + ) !*App { + const core_app = try CoreApp.create(global.alloc); + errdefer core_app.destroy(); + + // Create our runtime app + var app = try global.alloc.create(App); + errdefer global.alloc.destroy(app); + try app.init(core_app, config, opts.*); + errdefer app.terminate(); + + return app; + } + + /// Tick the event loop. This should be called whenever the "wakeup" + /// callback is invoked for the runtime. + export fn ghostty_app_tick(v: *App) void { + v.core_app.tick(v) catch |err| { + log.err("error app tick err={}", .{err}); + }; + } + + /// Return the userdata associated with the app. + export fn ghostty_app_userdata(v: *App) ?*anyopaque { + return v.opts.userdata; + } + + export fn ghostty_app_free(v: *App) void { + const core_app = v.core_app; + v.terminate(); + global.alloc.destroy(v); + core_app.destroy(); + } + + /// Update the focused state of the app. + export fn ghostty_app_set_focus( + app: *App, + focused: bool, + ) void { + app.focusEvent(focused); + } + + /// Notify the app of a global keypress capture. This will return + /// true if the key was captured by the app, in which case the caller + /// should not process the key. + export fn ghostty_app_key( + app: *App, + event: KeyEvent, + ) bool { + return app.keyEvent(.app, event.keyEvent()) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + }; + } + + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_app_key_is_binding( + app: *App, + event: KeyEvent, + ) bool { + const core_event = event.keyEvent().core() orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return app.core_app.keyEventIsBinding(app, core_event); + } + + /// Notify the app that the keyboard was changed. This causes the + /// keyboard layout to be reloaded from the OS. + export fn ghostty_app_keyboard_changed(v: *App) void { + v.reloadKeymap() catch |err| { + log.err("error reloading keyboard map err={}", .{err}); + return; + }; + } + + /// Open the configuration. + export fn ghostty_app_open_config(v: *App) void { + _ = v.performAction(.app, .open_config, {}) catch |err| { + log.err("error reloading config err={}", .{err}); + return; + }; + } + + /// Update the configuration to the provided config. This will propagate + /// to all surfaces as well. + export fn ghostty_app_update_config( + v: *App, + config: *const Config, + ) void { + v.core_app.updateConfig(v, config) catch |err| { + log.err("error updating config err={}", .{err}); + return; + }; + } + + /// Returns true if the app needs to confirm quitting. + export fn ghostty_app_needs_confirm_quit(v: *App) bool { + return v.core_app.needsConfirmQuit(); + } + + /// Returns true if the app has global keybinds. + export fn ghostty_app_has_global_keybinds(v: *App) bool { + return v.hasGlobalKeybinds(); + } + + /// Update the color scheme of the app. + export fn ghostty_app_set_color_scheme(v: *App, scheme_raw: c_int) void { + const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch { + log.warn( + "invalid color scheme to ghostty_surface_set_color_scheme value={}", + .{scheme_raw}, + ); + return; + }; + + v.core_app.colorSchemeEvent(v, scheme) catch |err| { + log.err("error setting color scheme err={}", .{err}); + return; + }; + } + + /// Returns initial surface options. + export fn ghostty_surface_config_new() apprt.Surface.Options { + return .{}; + } + + /// Create a new surface as part of an app. + export fn ghostty_surface_new( + app: *App, + opts: *const apprt.Surface.Options, + ) ?*Surface { + return surface_new_(app, opts) catch |err| { + log.err("error initializing surface err={}", .{err}); + return null; + }; + } + + fn surface_new_( + app: *App, + opts: *const apprt.Surface.Options, + ) !*Surface { + return try app.newSurface(opts.*); + } + + export fn ghostty_surface_free(ptr: *Surface) void { + ptr.app.closeSurface(ptr); + } + + /// Returns the userdata associated with the surface. + export fn ghostty_surface_userdata(surface: *Surface) ?*anyopaque { + return surface.userdata; + } + + /// Returns the app associated with a surface. + export fn ghostty_surface_app(surface: *Surface) *App { + return surface.app; + } + + /// Returns the config to use for surfaces that inherit from this one. + export fn ghostty_surface_inherited_config( + surface: *Surface, + source: apprt.surface.NewSurfaceContext, + ) Surface.Options { + return surface.newSurfaceOptions(source); + } + + /// Update the configuration to the provided config for only this surface. + export fn ghostty_surface_update_config( + surface: *Surface, + config: *const Config, + ) void { + surface.core_surface.updateConfig(config) catch |err| { + log.err("error updating config err={}", .{err}); + return; + }; + } + + /// Returns true if the surface needs to confirm quitting. + export fn ghostty_surface_needs_confirm_quit(surface: *Surface) bool { + return surface.core_surface.needsConfirmQuit(); + } + + /// Returns true if the surface process has exited. + export fn ghostty_surface_process_exited(surface: *Surface) bool { + return surface.core_surface.child_exited; + } + + /// Returns true if the surface has a selection. + export fn ghostty_surface_has_selection(surface: *Surface) bool { + return surface.core_surface.hasSelection(); + } + + /// Same as ghostty_surface_read_text but reads from the user selection, + /// if any. + export fn ghostty_surface_read_selection( + surface: *Surface, + result: *Text, + ) bool { + const core_surface = &surface.core_surface; + core_surface.renderer_state.mutex.lock(); + defer core_surface.renderer_state.mutex.unlock(); + + // If we don't have a selection, do nothing. + const core_sel = core_surface.io.terminal.screens.active.selection orelse return false; + + // Read the text from the selection. + return readTextLocked(surface, core_sel, result); + } + + /// Read some arbitrary text from the surface. + /// + /// This is an expensive operation so it shouldn't be called too + /// often. We recommend that callers cache the result and throttle + /// calls to this function. + export fn ghostty_surface_read_text( + surface: *Surface, + sel: Selection, + result: *Text, + ) bool { + surface.core_surface.renderer_state.mutex.lock(); + defer surface.core_surface.renderer_state.mutex.unlock(); + + const core_sel = sel.core( + surface.core_surface.renderer_state.terminal.screens.active, + ) orelse return false; + + return readTextLocked(surface, core_sel, result); + } + + fn readTextLocked( + surface: *Surface, + core_sel: terminal.Selection, + result: *Text, + ) bool { + const core_surface = &surface.core_surface; + + // Get our text directly from the core surface. + const text = core_surface.dumpTextLocked( + global.alloc, + core_sel, + ) catch |err| { + log.warn("error reading text err={}", .{err}); + return false; + }; + + const vp: CoreSurface.Text.Viewport = text.viewport orelse .{ + .tl_px_x = -1, + .tl_px_y = -1, + .offset_start = 0, + .offset_len = 0, + }; + + result.* = .{ + .tl_px_x = vp.tl_px_x, + .tl_px_y = vp.tl_px_y, + .offset_start = vp.offset_start, + .offset_len = vp.offset_len, + .text = text.text.ptr, + .text_len = text.text.len, + }; + + return true; + } + + export fn ghostty_surface_free_text(_: *Surface, ptr: *Text) void { + ptr.deinit(); + } + + /// Tell the surface that it needs to schedule a render + export fn ghostty_surface_refresh(surface: *Surface) void { + surface.refresh(); + } + + /// Tell the surface that it needs to schedule a render + /// call as soon as possible (NOW if possible). + export fn ghostty_surface_draw(surface: *Surface) void { + surface.draw(); + } + + /// Update the size of a surface. This will trigger resize notifications + /// to the pty and the renderer. + export fn ghostty_surface_set_size(surface: *Surface, w: u32, h: u32) void { + surface.updateSize(w, h); + } + + /// Return the size information a surface has. + export fn ghostty_surface_size(surface: *Surface) SurfaceSize { + const grid_size = surface.core_surface.size.grid(); + return .{ + .columns = grid_size.columns, + .rows = grid_size.rows, + .width_px = surface.core_surface.size.screen.width, + .height_px = surface.core_surface.size.screen.height, + .cell_width_px = surface.core_surface.size.cell.width, + .cell_height_px = surface.core_surface.size.cell.height, + }; + } + + /// Update the color scheme of the surface. + export fn ghostty_surface_set_color_scheme(surface: *Surface, scheme_raw: c_int) void { + const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch { + log.warn( + "invalid color scheme to ghostty_surface_set_color_scheme value={}", + .{scheme_raw}, + ); + return; + }; + + surface.colorSchemeCallback(scheme); + } + + /// Update the content scale of the surface. + export fn ghostty_surface_set_content_scale(surface: *Surface, x: f64, y: f64) void { + surface.updateContentScale(x, y); + } + + /// Update the focused state of a surface. + export fn ghostty_surface_set_focus(surface: *Surface, focused: bool) void { + surface.focusCallback(focused); + } + + /// Update the occlusion state of a surface. + export fn ghostty_surface_set_occlusion(surface: *Surface, visible: bool) void { + surface.occlusionCallback(visible); + } + + /// Filter the mods if necessary. This handles settings such as + /// `macos-option-as-alt`. The filtered mods should be used for + /// key translation but should NOT be sent back via the `_key` + /// function -- the original mods should be used for that. + export fn ghostty_surface_key_translation_mods( + surface: *Surface, + mods_raw: c_int, + ) c_int { + const mods: input.Mods = @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods_raw))), + )); + const result = mods.translation( + surface.core_surface.config.macos_option_as_alt orelse + surface.app.keyboardLayout().detectOptionAsAlt(), + ); + return @intCast(@as(input.Mods.Backing, @bitCast(result))); + } + + /// Send this for raw keypresses (i.e. the keyDown event on macOS). + /// This will handle the keymap translation and send the appropriate + /// key and char events. + export fn ghostty_surface_key( + surface: *Surface, + event: KeyEvent, + ) bool { + return surface.app.keyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + }; + } + + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_surface_key_is_binding( + surface: *Surface, + event: KeyEvent, + c_flags: ?*input.Binding.Flags.C, + ) bool { + const core_event = event.keyEvent().core() orelse { + log.warn("error processing key event", .{}); + return false; + }; + + const flags = surface.core_surface.keyEventIsBinding( + core_event, + ) orelse return false; + if (c_flags) |ptr| ptr.* = flags.cval(); + return true; + } + + /// Send raw text to the terminal. This is treated like a paste + /// so this isn't useful for sending escape sequences. For that, + /// individual key input should be used. + export fn ghostty_surface_text( + surface: *Surface, + ptr: [*]const u8, + len: usize, + ) void { + surface.textCallback(ptr[0..len]); + } + + /// Set the preedit text for the surface. This is used for IME + /// composition. If the length is 0, then the preedit text is cleared. + export fn ghostty_surface_preedit( + surface: *Surface, + ptr: [*]const u8, + len: usize, + ) void { + surface.preeditCallback(if (len == 0) null else ptr[0..len]); + } + + /// Returns true if the surface currently has mouse capturing + /// enabled. + export fn ghostty_surface_mouse_captured(surface: *Surface) bool { + return surface.core_surface.mouseCaptured(); + } + + /// Tell the surface that it needs to schedule a render + export fn ghostty_surface_mouse_button( + surface: *Surface, + action: input.MouseButtonState, + button: input.MouseButton, + mods: c_int, + ) bool { + return surface.mouseButtonCallback( + action, + button, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods))), + )), + ); + } + + /// Update the mouse position within the view. + export fn ghostty_surface_mouse_pos( + surface: *Surface, + x: f64, + y: f64, + mods: c_int, + ) void { + surface.cursorPosCallback( + x, + y, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods))), + )), + ); + } + + export fn ghostty_surface_mouse_scroll( + surface: *Surface, + x: f64, + y: f64, + scroll_mods: c_int, + ) void { + surface.scrollCallback( + x, + y, + @bitCast(@as(u8, @truncate(@as(c_uint, @bitCast(scroll_mods))))), + ); + } + + export fn ghostty_surface_mouse_pressure( + surface: *Surface, + stage_raw: u32, + pressure: f64, + ) void { + const stage = std.meta.intToEnum( + input.MousePressureStage, + stage_raw, + ) catch { + log.warn( + "invalid mouse pressure stage value={}", + .{stage_raw}, + ); + return; + }; + + surface.mousePressureCallback(stage, pressure); + } + + export fn ghostty_surface_ime_point( + surface: *Surface, + x: *f64, + y: *f64, + width: *f64, + height: *f64, + ) void { + const pos = surface.core_surface.imePoint(); + x.* = pos.x; + y.* = pos.y; + width.* = pos.width; + height.* = pos.height; + } + + /// Request that the surface become closed. This will go through the + /// normal trigger process that a close surface input binding would. + export fn ghostty_surface_request_close(ptr: *Surface) void { + ptr.core_surface.close(); + } + + /// Request that the surface split in the given direction. + export fn ghostty_surface_split(ptr: *Surface, direction: apprt.action.SplitDirection) void { + _ = ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .new_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; + } + + /// Focus on the next split (if any). + export fn ghostty_surface_split_focus( + ptr: *Surface, + direction: apprt.action.GotoSplit, + ) void { + _ = ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .goto_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; + } + + /// Resize the current split by moving the split divider in the given + /// direction. `direction` specifies which direction the split divider will + /// move relative to the focused split. `amount` is a fractional value + /// between 0 and 1 that specifies by how much the divider will move. + export fn ghostty_surface_split_resize( + ptr: *Surface, + direction: apprt.action.ResizeSplit.Direction, + amount: u16, + ) void { + _ = ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .resize_split, + .{ .direction = direction, .amount = amount }, + ) catch |err| { + log.err("error resizing split err={}", .{err}); + return; + }; + } + + /// Equalize the size of all splits in the current window. + export fn ghostty_surface_split_equalize(ptr: *Surface) void { + _ = ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .equalize_splits, + {}, + ) catch |err| { + log.err("error equalizing splits err={}", .{err}); + return; + }; + } + + /// Invoke an action on the surface. + export fn ghostty_surface_binding_action( + ptr: *Surface, + action_ptr: [*]const u8, + action_len: usize, + ) bool { + const action_str = action_ptr[0..action_len]; + const action = input.Binding.Action.parse(action_str) catch |err| { + log.err("error parsing binding action action={s} err={}", .{ action_str, err }); + return false; + }; + + return ptr.core_surface.performBindingAction(action) catch |err| { + log.err("error performing binding action action={f} err={}", .{ action, err }); + return false; + }; + } + + /// Complete a clipboard read request started via the read callback. + /// This can only be called once for a given request. Once it is called + /// with a request the request pointer will be invalidated. + export fn ghostty_surface_complete_clipboard_request( + ptr: *Surface, + str: [*:0]const u8, + state: *apprt.ClipboardRequest, + confirmed: bool, + ) void { + ptr.completeClipboardRequest( + std.mem.sliceTo(str, 0), + state, + confirmed, + ); + } + + export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { + return ptr.initInspector() catch |err| { + log.err("error initializing inspector err={}", .{err}); + return null; + }; + } + + export fn ghostty_inspector_free(ptr: *Surface) void { + ptr.freeInspector(); + } + + export fn ghostty_inspector_set_size(ptr: *Inspector, w: u32, h: u32) void { + ptr.updateSize(w, h); + } + + export fn ghostty_inspector_set_content_scale(ptr: *Inspector, x: f64, y: f64) void { + ptr.updateContentScale(x, y); + } + + export fn ghostty_inspector_mouse_button( + ptr: *Inspector, + action: input.MouseButtonState, + button: input.MouseButton, + mods: c_int, + ) void { + ptr.mouseButtonCallback( + action, + button, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods))), + )), + ); + } + + export fn ghostty_inspector_mouse_pos(ptr: *Inspector, x: f64, y: f64) void { + ptr.cursorPosCallback(x, y); + } + + export fn ghostty_inspector_mouse_scroll( + ptr: *Inspector, + x: f64, + y: f64, + scroll_mods: c_int, + ) void { + ptr.scrollCallback( + x, + y, + @bitCast(@as(u8, @truncate(@as(c_uint, @bitCast(scroll_mods))))), + ); + } + + export fn ghostty_inspector_key( + ptr: *Inspector, + action: input.Action, + key: input.Key, + c_mods: c_int, + ) void { + ptr.keyCallback( + action, + key, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(c_mods))), + )), + ) catch |err| { + log.err("error processing key event err={}", .{err}); + return; + }; + } + + export fn ghostty_inspector_text( + ptr: *Inspector, + str: [*:0]const u8, + ) void { + ptr.textCallback(std.mem.sliceTo(str, 0)); + } + + export fn ghostty_inspector_set_focus(ptr: *Inspector, focused: bool) void { + ptr.focusCallback(focused); + } + + /// Sets the window background blur on macOS to the desired value. + /// I do this in Zig as an extern function because I don't know how to + /// call these functions in Swift. + /// + /// This uses an undocumented, non-public API because this is what + /// every terminal appears to use, including Terminal.app. + export fn ghostty_set_window_background_blur( + app: *App, + window: *anyopaque, + ) void { + // This is only supported on macOS + if (comptime builtin.target.os.tag != .macos) return; + + const config = &app.config; + + // Do nothing if we don't have background transparency enabled + if (config.@"background-opacity" >= 1.0) return; + + const nswindow = objc.Object.fromId(window); + _ = CGSSetWindowBackgroundBlurRadius( + CGSDefaultConnectionForThread(), + nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), + @intCast(config.@"background-blur".cval()), + ); + } + + /// See ghostty_set_window_background_blur + extern "c" fn CGSSetWindowBackgroundBlurRadius(*anyopaque, usize, c_int) i32; + extern "c" fn CGSDefaultConnectionForThread() *anyopaque; + + // Darwin-only C APIs. + const Darwin = struct { + export fn ghostty_surface_set_display_id(ptr: *Surface, display_id: u32) void { + const surface = &ptr.core_surface; + _ = surface.renderer_thread.mailbox.push( + .{ .macos_display_id = display_id }, + .{ .forever = {} }, + ); + surface.renderer_thread.wakeup.notify() catch {}; + } + + /// This returns a CTFontRef that should be used for quicklook + /// highlighted text. This is always the primary font in use + /// regardless of the selected text. If coretext is not in use + /// then this will return nothing. + export fn ghostty_surface_quicklook_font(ptr: *Surface) ?*anyopaque { + // For non-CoreText we just return null. + if (comptime font.options.backend != .coretext) { + return null; + } + + // We'll need content scale so fail early if we can't get it. + const content_scale = ptr.getContentScale() catch return null; + + // Get the shared font grid. We acquire a read lock to + // read the font face. It should not be deferred since + // we're loading the primary face. + const grid = ptr.core_surface.renderer.font_grid; + grid.lock.lockShared(); + defer grid.lock.unlockShared(); + + const collection = &grid.resolver.collection; + const face = collection.getFace(.{}) catch return null; + + // We need to unscale the content scale. We apply the + // content scale to our font stack because we are rendering + // at 1x but callers of this should be using scaled or apply + // scale themselves. + const size: f32 = size: { + const num = face.font.copyAttribute(.size) orelse + break :size 12; + defer num.release(); + var v: f32 = 12; + _ = num.getValue(.float, &v); + break :size v; + }; + + const copy = face.font.copyWithAttributes( + size / content_scale.y, + null, + null, + ) catch return null; + + return copy; + } + + /// This returns the selected word for quicklook. This will populate + /// the buffer with the word under the cursor and the selection + /// info so that quicklook can be rendered. + /// + /// This does not modify the selection active on the surface (if any). + export fn ghostty_surface_quicklook_word( + ptr: *Surface, + result: *Text, + ) bool { + const surface = &ptr.core_surface; + surface.renderer_state.mutex.lock(); + defer surface.renderer_state.mutex.unlock(); + + // Get our word selection + const sel = sel: { + const screen: *terminal.Screen = surface.renderer_state.terminal.screens.active; + const pos = try ptr.getCursorPos(); + const pt_viewport = surface.posToViewport(pos.x, pos.y); + const pin = screen.pages.pin(.{ + .viewport = .{ + .x = pt_viewport.x, + .y = pt_viewport.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return false; + }; + break :sel surface.io.terminal.screens.active.selectWord( + pin, + surface.config.selection_word_chars, + ) orelse return false; + }; + + // Read the selection + return readTextLocked(ptr, sel, result); + } + + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { + return ptr.initMetal(.fromId(device)); + } + + export fn ghostty_inspector_metal_render( + ptr: *Inspector, + command_buffer: objc.c.id, + descriptor: objc.c.id, + ) void { + return ptr.renderMetal( + .fromId(command_buffer), + .fromId(descriptor), + ) catch |err| { + log.err("error rendering inspector err={}", .{err}); + return; + }; + } + + export fn ghostty_inspector_metal_shutdown(ptr: *Inspector) void { + if (ptr.backend) |v| { + v.deinit(); + ptr.backend = null; + } + } + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk.zig b/vendor/libghostty-vt/src/apprt/gtk.zig new file mode 100644 index 00000000..eb33c4e4 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk.zig @@ -0,0 +1,17 @@ +// The required comptime API for any apprt. +pub const App = @import("gtk/App.zig"); +pub const Surface = @import("gtk/Surface.zig"); +pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; + +// The exported API, custom for the apprt. +pub const class = @import("gtk/class.zig"); +pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef; +pub const pre_exec = @import("gtk/pre_exec.zig"); +pub const post_fork = @import("gtk/post_fork.zig"); + +test { + @import("std").testing.refAllDecls(@This()); + _ = @import("gtk/ext.zig"); + _ = @import("gtk/key.zig"); + _ = @import("gtk/portal.zig"); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/App.zig b/vendor/libghostty-vt/src/apprt/gtk/App.zig new file mode 100644 index 00000000..39c13c19 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/App.zig @@ -0,0 +1,93 @@ +/// This is the main entrypoint to the apprt for Ghostty. Ghostty will +/// initialize this in main to start the application.. +const App = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); +const Config = configpkg.Config; +const CoreApp = @import("../../App.zig"); + +const Application = @import("class/application.zig").Application; +const Surface = @import("Surface.zig"); +const ipcNewWindow = @import("ipc/new_window.zig").newWindow; + +const log = std.log.scoped(.gtk); + +/// This is detected by the Renderer, in which case it sends a `redraw_surface` +/// message so that we can call `drawFrame` ourselves from the app thread, +/// because GTK's `GLArea` does not support drawing from a different thread. +pub const must_draw_from_app_thread = true; + +/// GTK application ID +pub const application_id = @import("build/info.zig").application_id; + +/// GTK object path +pub const object_path = @import("build/info.zig").object_path; + +/// The GObject Application instance +app: *Application, + +pub fn init( + self: *App, + core_app: *CoreApp, + + // Required by the apprt interface but we don't use it. + opts: struct {}, +) !void { + _ = opts; + + const app: *Application = try .new(self, core_app); + errdefer app.unref(); + self.* = .{ .app = app }; + return; +} + +pub fn run(self: *App) !void { + try self.app.run(); +} + +pub fn terminate(self: *App) void { + // We force deinitialize the app. We don't unref because other things + // tend to have a reference at this point, so this just forces the + // disposal now. + self.app.deinit(); +} + +/// Called by CoreApp to wake up the event loop. +pub fn wakeup(self: *App) void { + self.app.wakeup(); +} + +pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), +) !bool { + return try self.app.performAction(target, action, value); +} + +/// Send the given IPC to a running Ghostty. Returns `true` if the action was +/// able to be performed, `false` otherwise. +/// +/// Note that this is a static function. Since this is called from a CLI app (or +/// some other process that is not Ghostty) there is no full-featured apprt App +/// to use. +pub fn performIpc( + alloc: Allocator, + target: apprt.ipc.Target, + comptime action: apprt.ipc.Action.Key, + value: apprt.ipc.Action.Value(action), +) !bool { + switch (action) { + .new_window => return try ipcNewWindow(alloc, target, value), + } +} + +/// Redraw the inspector for the given surface. +pub fn redrawInspector(_: *App, surface: *Surface) void { + surface.redrawInspector(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig new file mode 100644 index 00000000..71597367 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig @@ -0,0 +1,104 @@ +const Self = @This(); + +const std = @import("std"); +const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); +const CoreSurface = @import("../../Surface.zig"); +const ApprtApp = @import("App.zig"); +const Application = @import("class/application.zig").Application; +const Surface = @import("class/surface.zig").Surface; + +/// The GObject Surface +surface: *Surface, + +pub fn deinit(self: *Self) void { + _ = self; +} + +/// Returns the GObject surface for this apprt surface. This is a function +/// so we can add some extra logic if we ever have to here. +pub fn gobj(self: *Self) *Surface { + return self.surface; +} + +pub fn core(self: *Self) *CoreSurface { + // This asserts the non-optional because libghostty should only + // be calling this for initialized surfaces. + return self.surface.core().?; +} + +pub fn rtApp(self: *Self) *ApprtApp { + _ = self; + return Application.default().rt(); +} + +pub fn close(self: *Self, process_active: bool) void { + _ = process_active; + self.surface.close(); +} + +pub fn cgroup(self: *Self) ?[]const u8 { + return self.surface.cgroupPath(); +} + +pub fn getTitle(self: *Self) ?[:0]const u8 { + return self.surface.getTitle(); +} + +pub fn getContentScale(self: *const Self) !apprt.ContentScale { + return self.surface.getContentScale(); +} + +pub fn getSize(self: *const Self) !apprt.SurfaceSize { + return self.surface.getSize(); +} + +pub fn getCursorPos(self: *const Self) !apprt.CursorPos { + return self.surface.getCursorPos(); +} + +pub fn supportsClipboard( + self: *const Self, + clipboard_type: apprt.Clipboard, +) bool { + _ = self; + return switch (clipboard_type) { + .standard, + .selection, + .primary, + => true, + }; +} + +pub fn clipboardRequest( + self: *Self, + clipboard_type: apprt.Clipboard, + state: apprt.ClipboardRequest, +) !bool { + return try self.surface.clipboardRequest( + clipboard_type, + state, + ); +} + +pub fn setClipboard( + self: *Self, + clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, + confirm: bool, +) !void { + self.surface.setClipboard( + clipboard_type, + contents, + confirm, + ); +} + +pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + return try self.surface.defaultTermioEnv(); +} + +/// Redraw the inspector for our surface. +pub fn redrawInspector(self: *Self) void { + self.surface.redrawInspector(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig new file mode 100644 index 00000000..6f7be52d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig @@ -0,0 +1,122 @@ +const std = @import("std"); + +// Until the gobject bindings are built at the same time we are building +// Ghostty, we need to import `adwaita.h` directly to ensure that the version +// macros match the version of `libadwaita` that we are building/linking +// against. +const c = @cImport({ + @cInclude("adwaita.h"); +}); + +const adw = @import("adw"); + +const log = std.log.scoped(.gtk); + +pub const comptime_version: std.SemanticVersion = .{ + .major = c.ADW_MAJOR_VERSION, + .minor = c.ADW_MINOR_VERSION, + .patch = c.ADW_MICRO_VERSION, +}; + +pub fn getRuntimeVersion() std.SemanticVersion { + return .{ + .major = adw.getMajorVersion(), + .minor = adw.getMinorVersion(), + .patch = adw.getMicroVersion(), + }; +} + +pub fn logVersion() void { + log.info("libadwaita version build={f} runtime={f}", .{ + comptime_version, + getRuntimeVersion(), + }); +} + +/// Verifies that the running libadwaita version is at least the given +/// version. This will return false if Ghostty is configured to not build with +/// libadwaita. +/// +/// This can be run in both a comptime and runtime context. If it is run in a +/// comptime context, it will only check the version in the headers. If it is +/// run in a runtime context, it will check the actual version of the library we +/// are linked against. So generally you probably want to do both checks! +/// +/// This is inlined so that the comptime checks will disable the runtime checks +/// if the comptime checks fail. +pub inline fn atLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + // If our header has lower versions than the given version, we can return + // false immediately. This prevents us from compiling against unknown + // symbols and makes runtime checks very slightly faster. + if (comptime comptime_version.order(.{ + .major = major, + .minor = minor, + .patch = micro, + }) == .lt) return false; + + // If we're in comptime then we can't check the runtime version. + if (@inComptime()) return true; + + return runtimeAtLeast(major, minor, micro); +} + +/// Verifies that the libadwaita version at runtime is at least the given version. +/// +/// This function should be used in cases where the only the runtime behavior +/// is affected by the version check. For checks which would affect code +/// generation, use `atLeast`. +pub inline fn runtimeAtLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + // We use the functions instead of the constants such as c.GTK_MINOR_VERSION + // because the function gets the actual runtime version. + const runtime_version = getRuntimeVersion(); + return runtime_version.order(.{ + .major = major, + .minor = minor, + .patch = micro, + }) != .lt; +} + +test "versionAtLeast" { + const testing = std.testing; + + const funs = &.{ atLeast, runtimeAtLeast }; + inline for (funs) |fun| { + try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); + } +} + +// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+) +pub inline fn supportsDialogs() bool { + return atLeast(1, 5, 0); +} + +pub inline fn supportsTabOverview() bool { + return atLeast(1, 4, 0); +} + +pub inline fn supportsSwitchRow() bool { + return atLeast(1, 4, 0); +} + +pub inline fn supportsToolbarView() bool { + return atLeast(1, 4, 0); +} + +pub inline fn supportsBanner() bool { + return atLeast(1, 3, 0); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig new file mode 100644 index 00000000..4920ce6f --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig @@ -0,0 +1,174 @@ +//! Compiles a blueprint file using `blueprint-compiler`. This performs +//! additional checks to ensure that various minimum versions are met. +//! +//! Usage: blueprint.zig <major> <minor> <output> <input> +//! +//! Example: blueprint.zig 1 5 output.ui input.blp + +const std = @import("std"); + +pub const c = @cImport({ + @cInclude("adwaita.h"); +}); + +pub const blueprint_compiler_help = + \\ + \\When building from a Git checkout, Ghostty requires + \\version {f} or newer of `blueprint-compiler` as a + \\build-time dependency. Please install it, ensure that it + \\is available on your PATH, and then retry building Ghostty. + \\See `HACKING.md` for more details. + \\ + \\This message should *not* appear for normal users, who + \\should build Ghostty from official release tarballs instead. + \\Please consult https://ghostty.org/docs/install/build for + \\more information on the recommended build instructions. +; + +const adwaita_version = std.SemanticVersion{ + .major = c.ADW_MAJOR_VERSION, + .minor = c.ADW_MINOR_VERSION, + .patch = c.ADW_MICRO_VERSION, +}; +const required_blueprint_version = std.SemanticVersion{ + .major = 0, + .minor = 16, + .patch = 0, +}; + +pub fn main() !void { + var debug_allocator: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug_allocator.deinit(); + const alloc = debug_allocator.allocator(); + + // Get our args + var it = try std.process.argsWithAllocator(alloc); + defer it.deinit(); + _ = it.next(); // Skip argv0 + const arg_major = it.next() orelse return error.NoMajorVersion; + const arg_minor = it.next() orelse return error.NoMinorVersion; + const output = it.next() orelse return error.NoOutput; + const input = it.next() orelse return error.NoInput; + + const required_adwaita_version = std.SemanticVersion{ + .major = try std.fmt.parseUnsigned(u8, arg_major, 10), + .minor = try std.fmt.parseUnsigned(u8, arg_minor, 10), + .patch = 0, + }; + if (adwaita_version.order(required_adwaita_version) == .lt) { + std.debug.print( + \\`libadwaita` is too old. + \\ + \\Ghostty requires a version {f} or newer of `libadwaita` to + \\compile this blueprint. Please install it, ensure that it is + \\available on your PATH, and then retry building Ghostty. + , .{required_adwaita_version}); + std.posix.exit(1); + } + + // Version checks + { + var stdout: std.ArrayListUnmanaged(u8) = .empty; + defer stdout.deinit(alloc); + var stderr: std.ArrayListUnmanaged(u8) = .empty; + defer stderr.deinit(alloc); + + var blueprint_compiler = std.process.Child.init( + &.{ + "blueprint-compiler", + "--version", + }, + alloc, + ); + blueprint_compiler.stdout_behavior = .Pipe; + blueprint_compiler.stderr_behavior = .Pipe; + try blueprint_compiler.spawn(); + try blueprint_compiler.collectOutput( + alloc, + &stdout, + &stderr, + std.math.maxInt(u16), + ); + const term = blueprint_compiler.wait() catch |err| switch (err) { + error.FileNotFound => { + std.debug.print( + \\`blueprint-compiler` not found. + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); + std.posix.exit(1); + }, + else => return err, + }; + switch (term) { + .Exited => |rc| if (rc != 0) std.process.exit(1), + else => std.process.exit(1), + } + + const version = try std.SemanticVersion.parse(std.mem.trim( + u8, + stdout.items, + &std.ascii.whitespace, + )); + if (version.order(required_blueprint_version) == .lt) { + std.debug.print( + \\`blueprint-compiler` is the wrong version. + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); + std.posix.exit(1); + } + } + + // Compilation + { + var stdout: std.ArrayListUnmanaged(u8) = .empty; + defer stdout.deinit(alloc); + var stderr: std.ArrayListUnmanaged(u8) = .empty; + defer stderr.deinit(alloc); + + var blueprint_compiler = std.process.Child.init( + &.{ + "blueprint-compiler", + "compile", + "--output", + output, + input, + }, + alloc, + ); + blueprint_compiler.stdout_behavior = .Pipe; + blueprint_compiler.stderr_behavior = .Pipe; + try blueprint_compiler.spawn(); + try blueprint_compiler.collectOutput( + alloc, + &stdout, + &stderr, + std.math.maxInt(u16), + ); + const term = blueprint_compiler.wait() catch |err| switch (err) { + error.FileNotFound => { + std.debug.print( + \\`blueprint-compiler` not found. + ++ blueprint_compiler_help, + .{required_blueprint_version}, + ); + std.posix.exit(1); + }, + else => return err, + }; + + switch (term) { + .Exited => |rc| { + if (rc != 0) { + std.debug.print("{s}", .{stderr.items}); + std.process.exit(1); + } + }, + else => { + std.debug.print("{s}", .{stderr.items}); + std.process.exit(1); + }, + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig new file mode 100644 index 00000000..c50ea8cd --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig @@ -0,0 +1,277 @@ +//! This file contains a binary helper that builds our gresource XML +//! file that we can then use with `glib-compile-resources`. +//! +//! This binary is expected to be run from the Ghostty source root. +//! Litmus test: `src/apprt/gtk` should exist relative to the pwd. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const build_info = @import("info.zig"); + +/// The path to the Blueprint files. The folder structure is expected to be +/// `{version}/{name}.blp` where `version` is the major and minor +/// minimum adwaita version. +pub const ui_path = "src/apprt/gtk/ui"; + +/// The path to the CSS files. +pub const css_path = "src/apprt/gtk/css"; + +/// The possible icon sizes we'll embed into the gresource file. +/// If any size doesn't exist then it will be an error. We could +/// infer this completely from available files but we wouldn't be +/// able to error when they don't exist that way. +pub const icon_sizes: []const comptime_int = &.{ 16, 32, 128, 256, 512, 1024 }; + +/// The blueprint files that we will embed into the gresource file. +/// We can't look these up at runtime [easily] because we require the +/// compiled UI files as input. We can refactor this lator to maybe do +/// all of this automatically and ensure we have the right dependencies +/// setup in the build system. +/// +/// These will be asserted to exist at runtime. +pub const blueprints: []const Blueprint = &.{ + .{ .major = 1, .minor = 0, .name = "clipboard-confirmation-dialog" }, + .{ .major = 1, .minor = 4, .name = "clipboard-confirmation-dialog" }, + .{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" }, + .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, + .{ .major = 1, .minor = 2, .name = "debug-warning" }, + .{ .major = 1, .minor = 3, .name = "debug-warning" }, + .{ .major = 1, .minor = 5, .name = "imgui-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-window" }, + .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 2, .name = "search-overlay" }, + .{ .major = 1, .minor = 2, .name = "key-state-overlay" }, + .{ .major = 1, .minor = 5, .name = "split-tree" }, + .{ .major = 1, .minor = 5, .name = "split-tree-split" }, + .{ .major = 1, .minor = 2, .name = "surface" }, + .{ .major = 1, .minor = 5, .name = "surface-scrolled-window" }, + .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, + .{ .major = 1, .minor = 5, .name = "tab" }, + .{ .major = 1, .minor = 5, .name = "title-dialog" }, + .{ .major = 1, .minor = 5, .name = "window" }, + .{ .major = 1, .minor = 5, .name = "command-palette" }, +}; + +/// CSS files in css_path +pub const css = [_][]const u8{ + "style.css", + "style-dark.css", + "style-hc.css", + "style-hc-dark.css", +}; + +pub const Blueprint = struct { + major: u16, + minor: u16, + name: []const u8, +}; + +/// The list of filepaths that we depend on. Used for the build +/// system to have proper caching. +pub const file_inputs = deps: { + const total = (icon_sizes.len * 2) + blueprints.len + css.len; + var deps: [total][]const u8 = undefined; + var index: usize = 0; + for (icon_sizes) |size| { + deps[index] = std.fmt.comptimePrint("images/gnome/{d}.png", .{size}); + deps[index + 1] = std.fmt.comptimePrint("images/gnome/{d}.png", .{size * 2}); + index += 2; + } + for (blueprints) |bp| { + deps[index] = std.fmt.comptimePrint("{s}/{d}.{d}/{s}.blp", .{ + ui_path, + bp.major, + bp.minor, + bp.name, + }); + index += 1; + } + for (css) |name| { + deps[index] = std.fmt.comptimePrint("{s}/{s}", .{ css_path, name }); + index += 1; + } + break :deps deps; +}; + +/// Returns the matching blueprint resource path for the given blueprint +/// definition. This will fail at compile time if the blueprint is not +/// found. +/// +/// Must be called at comptime. +pub fn blueprint(comptime bp: Blueprint) [:0]const u8 { + // The comptime block around this whole thing forces an error if + // the caller attempts to call this function at runtime. + comptime { + for (blueprints) |candidate| { + if (candidate.major == bp.major and + candidate.minor == bp.minor and + std.mem.eql(u8, candidate.name, bp.name)) + { + return std.fmt.comptimePrint("{s}/ui/{d}.{d}/{s}.ui", .{ + build_info.resource_path, + candidate.major, + candidate.minor, + candidate.name, + }); + } + } + + @compileError("invalid blueprint"); + } +} + +pub fn main() !void { + var debug_allocator: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug_allocator.deinit(); + const alloc = debug_allocator.allocator(); + + // Collect the UI files that are passed in as arguments. + var ui_files: std.ArrayListUnmanaged([]const u8) = .empty; + defer { + for (ui_files.items) |item| alloc.free(item); + ui_files.deinit(alloc); + } + var it = try std.process.argsWithAllocator(alloc); + defer it.deinit(); + while (it.next()) |arg| { + if (!std.mem.endsWith(u8, arg, ".ui")) continue; + try ui_files.append( + alloc, + try alloc.dupe(u8, arg), + ); + } + + var buf: [4096]u8 = undefined; + var stdout = std.fs.File.stdout().writer(&buf); + const writer = &stdout.interface; + try writer.writeAll( + \\<?xml version="1.0" encoding="UTF-8"?> + \\<gresources> + \\ + ); + + try genRoot(writer); + try genIcons(writer); + try genUi(alloc, writer, &ui_files); + + try writer.writeAll( + \\</gresources> + \\ + ); + + try stdout.end(); +} + +/// Generate the icon resources. This works by looking up all the icons +/// specified by `icon_sizes` in `images/icons/`. They are asserted to exist +/// by trying to access the file. +fn genIcons(writer: *std.Io.Writer) !void { + try writer.print( + \\ <gresource prefix="{s}/icons"> + \\ + , .{build_info.resource_path}); + + const cwd = std.fs.cwd(); + inline for (icon_sizes) |size| { + // 1x + { + const alias = std.fmt.comptimePrint("{d}x{d}", .{ size, size }); + const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size}); + try cwd.access(source, .{}); + try writer.print( + \\ <file alias="{s}/apps/{s}.png">{s}</file> + \\ + , + .{ alias, build_info.base_application_id, source }, + ); + } + + // 2x + { + const alias = std.fmt.comptimePrint("{d}x{d}@2", .{ size, size }); + const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size * 2}); + try cwd.access(source, .{}); + try writer.print( + \\ <file alias="{s}/apps/{s}.png">{s}</file> + \\ + , + .{ alias, build_info.base_application_id, source }, + ); + } + } + + try writer.writeAll( + \\ </gresource> + \\ + ); +} + +/// Generate the resources at the root prefix. +fn genRoot(writer: *std.Io.Writer) !void { + try writer.print( + \\ <gresource prefix="{s}"> + \\ + , .{build_info.resource_path}); + + const cwd = std.fs.cwd(); + inline for (css) |name| { + const source = std.fmt.comptimePrint( + "{s}/{s}", + .{ css_path, name }, + ); + try cwd.access(source, .{}); + try writer.print( + \\ <file compressed="true" alias="{s}">{s}</file> + \\ + , + .{ name, source }, + ); + } + + try writer.writeAll( + \\ </gresource> + \\ + ); +} + +/// Generate all the UI resources. This works by looking up all the +/// blueprint files in `${ui_path}/{major}.{minor}/{name}.blp` and +/// assuming these will be +fn genUi( + alloc: Allocator, + writer: *std.Io.Writer, + files: *const std.ArrayListUnmanaged([]const u8), +) !void { + try writer.print( + \\ <gresource prefix="{s}/ui"> + \\ + , .{build_info.resource_path}); + + for (files.items) |ui_file| { + for (blueprints) |bp| { + const expected = try std.fmt.allocPrint( + alloc, + "/{d}.{d}/{s}.ui", + .{ bp.major, bp.minor, bp.name }, + ); + defer alloc.free(expected); + if (!std.mem.endsWith(u8, ui_file, expected)) continue; + try writer.print( + " <file compressed=\"true\" preprocess=\"xml-stripblanks\" alias=\"{d}.{d}/{s}.ui\">{s}</file>\n", + .{ bp.major, bp.minor, bp.name, ui_file }, + ); + break; + } else { + // The for loop never broke which means it didn't find + // a matching blueprint for this input. + return error.BlueprintNotFound; + } + } + + try writer.writeAll( + \\ </gresource> + \\ + ); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/info.zig b/vendor/libghostty-vt/src/apprt/gtk/build/info.zig new file mode 100644 index 00000000..fc6478d8 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/build/info.zig @@ -0,0 +1,18 @@ +const builtin = @import("builtin"); + +/// Base application ID +pub const base_application_id = "com.mitchellh.ghostty"; + +/// GTK application ID +pub const application_id = switch (builtin.mode) { + .Debug, .ReleaseSafe => base_application_id ++ "-debug", + .ReleaseFast, .ReleaseSmall => base_application_id, +}; + +pub const resource_path = "/com/mitchellh/ghostty"; + +/// GTK object path +pub const object_path = switch (builtin.mode) { + .Debug, .ReleaseSafe => resource_path ++ "_debug", + .ReleaseFast, .ReleaseSmall => resource_path, +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/cgroup.zig b/vendor/libghostty-vt/src/apprt/gtk/cgroup.zig new file mode 100644 index 00000000..868aa268 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/cgroup.zig @@ -0,0 +1,115 @@ +/// Contains all the logic for putting individual surfaces into +/// transient systemd scopes. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../../quirks.zig").inlineAssert; + +const gio = @import("gio"); +const glib = @import("glib"); + +const internal_os = @import("../../os/main.zig"); + +const log = std.log.scoped(.gtk_systemd_cgroup); + +pub const Options = struct { + memory_high: ?u64 = null, + tasks_max: ?u64 = null, +}; + +pub fn fmtScope(buf: []u8, pid: u32) [:0]const u8 { + const fmt = "app-ghostty-surface-transient-{}.scope"; + + assert(buf.len >= fmt.len - 2 + std.math.log10_int(@as(usize, std.math.maxInt(@TypeOf(pid)))) + 1); + + return std.fmt.bufPrintZ(buf, fmt, .{pid}) catch unreachable; +} + +/// Create a transient systemd scope unit for the given process and +/// move the process into it. +pub fn createScope( + dbus: *gio.DBusConnection, + pid: u32, + options: Options, +) error{DbusCallFailed}!void { + // The unit name needs to be unique. We use the PID for this. + var name_buf: [256]u8 = undefined; + const name = fmtScope(&name_buf, pid); + + const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))"); + defer glib.free(builder_type); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(builder_type); + + builder.add("s", name.ptr); + builder.add("s", "fail"); + + { + // Properties + const properties_type = glib.VariantType.new("a(sv)"); + defer glib.free(properties_type); + + builder.open(properties_type); + defer builder.close(); + + if (options.memory_high) |value| { + builder.add("(sv)", "MemoryHigh", glib.Variant.newUint64(value)); + } + + if (options.tasks_max) |value| { + builder.add("(sv)", "TasksMax", glib.Variant.newUint64(value)); + } + + // https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html + builder.add("(sv)", "ManagedOOMMemoryPressure", glib.Variant.newString("kill")); + + // PID to move into the unit + const pids_value_type = glib.VariantType.new("u"); + defer glib.free(pids_value_type); + + const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32)); + + builder.add("(sv)", "PIDs", pids_value); + } + + { + // Aux - unused but must be present + const aux_type = glib.VariantType.new("a(sa(sv))"); + defer glib.free(aux_type); + + builder.open(aux_type); + defer builder.close(); + } + + var err: ?*glib.Error = null; + defer if (err) |e| e.free(); + + const reply_type = glib.VariantType.new("(o)"); + defer glib.free(reply_type); + + const value = builder.end(); + + const reply = dbus.callSync( + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "StartTransientUnit", + value, + reply_type, + .{}, + -1, + null, + &err, + ) orelse { + if (err) |e| log.err( + "creating transient cgroup scope failed code={} err={s}", + .{ + e.f_code, + if (e.f_message) |msg| msg else "(no message)", + }, + ); + return error.DbusCallFailed; + }; + defer reply.unref(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class.zig b/vendor/libghostty-vt/src/apprt/gtk/class.zig new file mode 100644 index 00000000..942666cf --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class.zig @@ -0,0 +1,367 @@ +//! This files contains all the GObject classes for the GTK apprt +//! along with helpers to work with them. + +const std = @import("std"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const ext = @import("ext.zig"); +pub const Application = @import("class/application.zig").Application; +pub const Window = @import("class/window.zig").Window; +pub const Config = @import("class/config.zig").Config; +pub const Surface = @import("class/surface.zig").Surface; + +/// Common methods for all GObject classes we create. +pub fn Common( + comptime Self: type, + comptime Private: ?type, +) type { + return struct { + /// Upcast our type to a parent type or interface. This will fail at + /// compile time if the cast isn't 100% safe. For unsafe casts, + /// use `gobject.ext.cast` instead. We don't have a helper for that + /// because its uncommon and unsafe behavior should be noisier. + pub fn as(self: *Self, comptime T: type) *T { + return gobject.ext.as(T, self); + } + + /// Increase the reference count of the object. + pub fn ref(self: *Self) *Self { + return @ptrCast(@alignCast(gobject.Object.ref(self.as(gobject.Object)))); + } + + /// If the reference count is 1 and the object is floating, clear the + /// floating attribute. Otherwise, increase the reference count by 1. + pub fn refSink(self: *Self) *Self { + return @ptrCast(@alignCast(gobject.Object.refSink(self.as(gobject.Object)))); + } + + /// Decrease the reference count of the object. + pub fn unref(self: *Self) void { + gobject.Object.unref(self.as(gobject.Object)); + } + + /// Access the private data of the object. This should be forwarded + /// via a non-pub const usually. + pub const private = if (Private) |P| (struct { + fn private(self: *Self) *P { + return gobject.ext.impl_helpers.getPrivate( + self, + P, + P.offset, + ); + } + }).private else {}; + + /// Get the class for the object. + /// + /// This _seems_ ugly and unsafe but this is how GObject + /// works under the hood. From the [GObject Type System + /// Concepts](https://docs.gtk.org/gobject/concepts.html) documentation: + /// + /// Every object must define two structures: its class structure + /// and its instance structure. All class structures must contain + /// as first member a GTypeClass structure. All instance structures + /// must contain as first member a GTypeInstance structure. + /// … + /// These constraints allow the type system to make sure that + /// every object instance (identified by a pointer to the object’s + /// instance structure) contains in its first bytes a pointer to the + /// object’s class structure. + /// … + /// The C standard mandates that the first field of a C structure is + /// stored starting in the first byte of the buffer used to hold the + /// structure’s fields in memory. This means that the first field of + /// an instance of an object B is A’s first field which in turn is + /// GTypeInstance‘s first field which in turn is g_class, a pointer + /// to B’s class structure. + /// + /// This means that to access the class structure for an object you cast it + /// to `*gobject.TypeInstance` and then access the `f_g_class` field. + /// + /// https://gitlab.gnome.org/GNOME/glib/-/blob/2c08654b62d52a31c4e4d13d7d85e12b989e72be/gobject/gtype.h#L555-571 + /// https://gitlab.gnome.org/GNOME/glib/-/blob/2c08654b62d52a31c4e4d13d7d85e12b989e72be/gobject/gtype.h#L2673 + /// + pub fn getClass(self: *Self) ?*Self.Class { + const type_instance: *gobject.TypeInstance = @ptrCast(self); + return @ptrCast(type_instance.f_g_class orelse return null); + } + + /// Define a virtual method. The `Self.Class` type must have a field + /// named `name` which is a function pointer in the following form: + /// + /// ?*const fn (*Self) callconv(.c) void + /// + /// The virtual method may take additional parameters and specify + /// a non-void return type. The parameters and return type must be + /// valid for the C calling convention. + pub fn defineVirtualMethod( + comptime name: [:0]const u8, + ) type { + return struct { + pub fn call( + class: anytype, + object: *ClassInstance(@TypeOf(class)), + params: anytype, + ) (fn_info.return_type orelse void) { + const func = @field( + gobject.ext.as(Self.Class, class), + name, + ).?; + @call(.auto, func, .{ + gobject.ext.as(Self, object), + } ++ params); + } + + pub fn implement( + class: anytype, + implementation: *const ImplementFunc(@TypeOf(class)), + ) void { + @field(gobject.ext.as( + Self.Class, + class, + ), name) = @ptrCast(implementation); + } + + /// The type info of the virtual method. + const fn_info = fn_info: { + // This is broken down like this so its slightly more + // readable. We expect a field named "name" on the Class + // with the rough type of `?*const fn` and we need the + // function info. + const Field = @FieldType(Self.Class, name); + const opt = @typeInfo(Field).optional; + const ptr = @typeInfo(opt.child).pointer; + break :fn_info @typeInfo(ptr.child).@"fn"; + }; + + /// The instance type for a class. + fn ClassInstance(comptime T: type) type { + return @typeInfo(T).pointer.child.Instance; + } + + /// The function type for implementations. This is the same type + /// as the virtual method but the self parameter points to the + /// target instead of the original class. + fn ImplementFunc(comptime T: type) type { + var params: [fn_info.params.len]std.builtin.Type.Fn.Param = undefined; + @memcpy(¶ms, fn_info.params); + params[0].type = *ClassInstance(T); + return @Type(.{ .@"fn" = .{ + .calling_convention = fn_info.calling_convention, + .is_generic = fn_info.is_generic, + .is_var_args = fn_info.is_var_args, + .return_type = fn_info.return_type, + .params = ¶ms, + } }); + } + }; + } + + /// A helper that creates a property that reads and writes a + /// private field with only shallow copies. This is good for primitives + /// such as bools, numbers, etc. + pub fn privateShallowFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return gobject.ext.privateFieldAccessor( + Self, + Private.?, + &Private.?.offset, + name, + ); + } + + /// A helper that can be used to create a property that reads and + /// writes a private boxed gobject field type. + /// + /// Reading the property will result in allocating a pointer and + /// setting it will free the previous pointer. + /// + /// The object class (Self) must still free the private field + /// in finalize! + pub fn privateBoxedFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return .{ + .getter = &struct { + fn get(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + @field(private(self), name), + ); + } + }.get, + .setter = &struct { + fn set(self: *Self, value: *const gobject.Value) void { + const priv = private(self); + if (@field(priv, name)) |v| { + ext.boxedFree( + @typeInfo(@TypeOf(v)).pointer.child, + v, + ); + } + + const T = @TypeOf(@field(priv, name)); + @field( + priv, + name, + ) = gobject.ext.Value.dup(value, T); + } + }.set, + }; + } + + /// A helper that can be used to create a property that reads and + /// writes a private field gobject field type (reference counted). + /// + /// Reading the property will result in taking a reference to the + /// value and writing the property will unref the previous value. + /// + /// The object class (Self) must still free the private field + /// in finalize! + pub fn privateObjFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return .{ + .getter = &struct { + fn get(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + @field(private(self), name), + ); + } + }.get, + .setter = &struct { + fn set(self: *Self, value: *const gobject.Value) void { + const priv = private(self); + if (@field(priv, name)) |v| v.unref(); + + const T = @TypeOf(@field(priv, name)); + @field( + priv, + name, + ) = gobject.ext.Value.dup(value, T); + } + }.set, + }; + } + + /// A helper that can be used to create a property that reads and + /// writes a private `?[:0]const u8` field type. + /// + /// Reading the property will result in a copy of the string + /// and callers are responsible for freeing it. + /// + /// Writing the property will free the previous value and copy + /// the new value into the private field. + /// + /// The object class (Self) must still free the private field + /// in finalize! + pub fn privateStringFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + const S = struct { + fn getter(self: *Self) ?[:0]const u8 { + return @field(private(self), name); + } + + fn setter(self: *Self, value: ?[:0]const u8) void { + const priv = private(self); + if (@field(priv, name)) |v| { + glib.free(@ptrCast(@constCast(v))); + } + + // We don't need to copy this because it was already + // copied by the typedAccessor. + @field(priv, name) = value; + } + }; + + return gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = S.getter, + .getter_transfer = .none, + .setter = S.setter, + .setter_transfer = .full, + }, + ); + } + + /// Common class functions. + pub const Class = struct { + pub fn as(class: *Self.Class, comptime T: type) *T { + return gobject.ext.as(T, class); + } + + /// Bind a template child to a private entry in the class. + pub const bindTemplateChildPrivate = if (Private) |P| (struct { + pub fn bindTemplateChildPrivate( + class: *Self.Class, + comptime name: [:0]const u8, + comptime options: gtk.ext.BindTemplateChildOptions, + ) void { + gtk.ext.impl_helpers.bindTemplateChildPrivate( + class, + name, + P, + P.offset, + options, + ); + } + }).bindTemplateChildPrivate else {}; + + /// Bind a function pointer to a template callback symbol. + pub fn bindTemplateCallback( + class: *Self.Class, + comptime name: [:0]const u8, + comptime func: anytype, + ) void { + { + const ptr_ti = @typeInfo(@TypeOf(func)); + if (ptr_ti != .pointer) { + @compileError("bound function must be a pointer type"); + } + if (ptr_ti.pointer.size != .one) { + @compileError("bound function must be a pointer to a function"); + } + + const func_ti = @typeInfo(ptr_ti.pointer.child); + if (func_ti != .@"fn") { + @compileError("bound function must be a function pointer"); + } + if (func_ti.@"fn".return_type == bool) { + // glib booleans are ints and returning a Zig bool type + // I think uses a byte and causes ABI issues. + @compileError("bound function must return c_int instead of bool"); + } + } + + gtk.Widget.Class.bindTemplateCallbackFull( + class.as(gtk.Widget.Class), + name, + @ptrCast(func), + ); + } + }; + }; +} + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig new file mode 100644 index 00000000..873674ce --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig @@ -0,0 +1,2945 @@ +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 build_config = @import("../../../build_config.zig"); +const build_info = @import("../build/info.zig"); +const state = &@import("../../../global.zig").state; +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const CoreApp = @import("../../../App.zig"); +const configpkg = @import("../../../config.zig"); +const input = @import("../../../input.zig"); +const internal_os = @import("../../../os/main.zig"); +const systemd = @import("../../../os/systemd.zig"); +const terminal = @import("../../../terminal/main.zig"); +const xev = @import("../../../global.zig").xev; +const Binding = @import("../../../input.zig").Binding; +const CoreConfig = configpkg.Config; +const CoreSurface = @import("../../../Surface.zig"); +const lib = @import("../../../lib/main.zig"); + +const ext = @import("../ext.zig"); +const key = @import("../key.zig"); +const adw_version = @import("../adw_version.zig"); +const gtk_version = @import("../gtk_version.zig"); +const winprotopkg = @import("../winproto.zig"); +const ApprtApp = @import("../App.zig"); +const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Config = @import("config.zig").Config; +const Surface = @import("surface.zig").Surface; +const SplitTree = @import("split_tree.zig").SplitTree; +const Window = @import("window.zig").Window; +const Tab = @import("tab.zig").Tab; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; +const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; +const OpenURI = @import("../portal.zig").OpenURI; + +const log = std.log.scoped(.gtk_ghostty_application); + +/// Function used to funnel GLib/GObject/GTK log messages into Zig's logging +/// system rather than just getting dumped directly to stderr. +fn glibLogWriterFunction( + level: glib.LogLevelFlags, + fields: [*]const glib.LogField, + n_fields: usize, + _: ?*anyopaque, +) callconv(.c) glib.LogWriterOutput { + const glib_log = std.log.scoped(.glib); + + var message_: ?[]const u8 = null; + var domain_: ?[]const u8 = null; + for (0..n_fields) |i| { + const field = fields[i]; + const k = std.mem.span(field.f_key orelse continue); + const v: []const u8 = v: { + if (field.f_length >= 0) { + const v: [*]const u8 = @ptrCast(field.f_value orelse continue); + break :v v[0..@intCast(field.f_length)]; + } + const v: [*:0]const u8 = @ptrCast(field.f_value orelse continue); + break :v std.mem.span(v); + }; + if (std.mem.eql(u8, k, "MESSAGE")) { + message_ = v; + continue; + } + if (std.mem.eql(u8, k, "GLIB_DOMAIN")) { + domain_ = v; + continue; + } + } + + const message = message_ orelse return .unhandled; + const domain = domain_ orelse "«unknown»"; + + if (level.level_error) { + glib_log.err("ERROR: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_critical) { + glib_log.err("CRITICAL: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_warning) { + glib_log.warn("WARNING: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_message) { + glib_log.info("MESSAGE: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_info) { + glib_log.info("INFO: {s}: {s}", .{ domain, message }); + return .handled; + } + if (level.level_debug) { + glib_log.debug("DEBUG: {s}: {s}", .{ domain, message }); + return .handled; + } + glib_log.debug("UNKNOWN: {s}: {s}", .{ domain, message }); + return .handled; +} + +/// The primary entrypoint for the Ghostty GTK application. +/// +/// This requires a `ghostty.App` and `ghostty.Config` and takes +/// care of the rest. Call `run` to run the application to completion. +pub const Application = extern struct { + /// This type creates a new GObject class. Since the Application is + /// the primary entrypoint I'm going to use this as a place to document + /// how this all works and where you can find resources for it, but + /// this applies to any other GObject class within this apprt. + /// + /// The various fields (parent_instance) and constants (Parent, + /// getGObjectType, etc.) are mandatory "interfaces" for zig-gobject + /// to create a GObject class. + /// + /// I found these to be the best resources: + /// + /// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/extensions/gobject2.zig + /// * https://github.com/ianprime0509/zig-gobject/blob/d7f1edaf50193d49b56c60568dfaa9f23195565b/example/src/custom_class.zig + /// + const Self = @This(); + + parent_instance: Parent, + pub const Parent = adw.Application; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyApplication", + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + "config", + Self, + ?*Config, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Config, + .{ + .getter = Self.getConfig, + .getter_transfer = .full, + }, + ), + }, + ); + }; + }; + + const Private = struct { + /// The apprt App. This is annoying that we need this it'd be + /// nicer to just make THIS the apprt app but the current libghostty + /// API doesn't allow that. + rt_app: *ApprtApp, + + /// The libghostty App instance. + core_app: *CoreApp, + + /// The configuration for the application. + config: *Config, + + /// State and logic for the underlying windowing protocol. + winproto: winprotopkg.App, + + /// The global shortcut logic. + global_shortcuts: *GlobalShortcuts, + + /// This is set to true so long as we request a window exactly + /// once. This prevents quitting the app before we've shown one + /// window. + requested_window: bool = false, + + /// This is set to false internally when the event loop + /// should exit and the application should quit. This must + /// only be set by the main loop thread. + running: bool = false, + + /// The timer used to quit the application after the last window is + /// closed. Even if there is no quit delay set, this is the state + /// used to determine to close the app. + quit_timer: union(enum) { + off, + active: c_uint, + expired, + } = .off, + + /// If non-null, we're currently showing a config errors dialog. + /// This is a WeakRef because the dialog can close on its own + /// outside of our own lifecycle and that's okay. + config_errors_dialog: WeakRef(ConfigErrorsDialog) = .empty, + + /// glib source for our signal handler. + signal_source: ?c_uint = null, + + /// CSS Provider for any styles based on Ghostty configuration values. + css_provider: *gtk.CssProvider, + + /// Providers for loading custom stylesheets defined by user + custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .empty, + + /// A copy of the LANG environment variable that was provided to Ghostty + /// by the system. If this is null, the LANG environment variable did + /// not exist in Ghostty's environment variable. + saved_language: ?[:0]const u8 = null, + + open_uri: OpenURI = undefined, + + pub var offset: c_int = 0; + }; + + /// Get this application as the default, allowing access to its + /// properties globally. + /// + /// This asserts that there is a default application and that the + /// default application is a GhosttyApplication. The program would have + /// to be in a very bad state for this to be violated. + pub fn default() *Self { + const app = gio.Application.getDefault().?; + return gobject.ext.cast(Self, app).?; + } + + /// Creates a new Application instance. + /// + /// This does a lot more work than a typical class instantiation, + /// because we expect that this is the main program entrypoint. + /// + /// The only failure mode of initializing the application is early OOM. + /// Early OOM can't be recovered from. Every other error is mapped to + /// some degraded state where we can at least show a window with an error. + pub fn new( + rt_app: *ApprtApp, + core_app: *CoreApp, + ) Allocator.Error!*Self { + const alloc = core_app.alloc; + + // Capture GLib/GObject/GTK log messages and funnel them through Zig's + // logging system rather than just getting dumped directly to stderr. + _ = glib.logSetWriterFunc(glibLogWriterFunction, null, null); + + // Log our GTK versions + gtk_version.logVersion(); + adw_version.logVersion(); + + // Load our configuration. + var config = CoreConfig.load(alloc) catch |err| err: { + // If we fail to load the configuration, then we should log + // the error in the diagnostics so it can be shown to the user. + // We can still load a default which only fails for OOM, allowing + // us to startup. + var def: CoreConfig = try .default(alloc); + errdefer def.deinit(); + try def.addDiagnosticFmt( + "error loading user configuration: {}", + .{err}, + ); + + break :err def; + }; + defer config.deinit(); + + const saved_language: ?[:0]const u8 = saved_language: { + const old_language = old_language: { + const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null; + defer result.deinit(alloc); + break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null; + }; + + if (config.language) |language| _ = internal_os.setenv("LANG", language); + + break :saved_language old_language; + }; + + // Set gettext global domain to be our app so that our unqualified + // translations map to our translations. + internal_os.i18n.initGlobalDomain() catch |err| { + // Failures shuldn't stop application startup. Our app may + // not translate correctly but it should still work. In the + // future we may want to add this to the GUI to show. + log.warn("i18n initialization failed error={}", .{err}); + }; + + // Setup our GTK init env vars + setGtkEnv(&config) catch |err| switch (err) { + error.NoSpaceLeft => { + // If we fail to set GTK environment variables then we still + // try to start the application... + log.warn( + "error setting GTK environment variables err={}", + .{err}, + ); + }, + }; + adw.init(); + + const single_instance = switch (config.@"gtk-single-instance") { + .true => true, + .false => false, + // This should have been resolved to true/false during config loading. + .detect => unreachable, + }; + + // Setup the flags for our application. + const app_flags: gio.ApplicationFlags = app_flags: { + var flags: gio.ApplicationFlags = .flags_default_flags; + if (!single_instance) flags.non_unique = true; + break :app_flags flags; + }; + + // Our app ID determines uniqueness and maps to our desktop file. + // We append "-debug" to the ID if we're in debug mode so that we + // can develop Ghostty in Ghostty. + const app_id: [:0]const u8 = app_id: { + if (config.class) |class| { + if (gio.Application.idIsValid(class) != 0) { + break :app_id class; + } else { + log.warn("invalid 'class' in config, ignoring", .{}); + } + } + + break :app_id build_info.application_id; + }; + + const display: *gdk.Display = gdk.Display.getDefault() orelse { + // I'm unsure of any scenario where this happens. Because we don't + // want to litter null checks everywhere, we just exit here. + log.warn("gdk display is null, exiting", .{}); + std.posix.exit(1); + }; + + // Setup our windowing protocol logic + var wp: winprotopkg.App = winprotopkg.App.init( + alloc, + display, + app_id, + &config, + ) catch |err| wp: { + // If we fail to detect or setup the windowing protocol + // specifies, we fallback to a noop implementation so we can + // still launch. + log.warn("error initializing windowing protocol err={}", .{err}); + break :wp .{ .none = .{} }; + }; + errdefer wp.deinit(); + log.debug("windowing protocol={s}", .{@tagName(wp)}); + + // Create our GTK Application which encapsulates our process. + log.debug("creating GTK application id={s} single-instance={}", .{ + app_id, + single_instance, + }); + + // Wrap our configuration in a GObject. + const config_obj: *Config = try .new(alloc, &config); + errdefer config_obj.unref(); + + // Internally, GTK ensures that only one instance of this provider + // exists in the provider list for the display. + const css_provider = gtk.CssProvider.new(); + gtk.StyleContext.addProviderForDisplay( + display, + css_provider.as(gtk.StyleProvider), + gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, + ); + errdefer css_provider.unref(); + + // Initialize the app. + const self = gobject.ext.newInstance(Self, .{ + .application_id = app_id.ptr, + .flags = app_flags, + + // Force the resource path to a known value so it doesn't depend + // on the app id (which changes between debug/release and can be + // user-configured) and force it to load in compiled resources. + .resource_base_path = build_info.resource_path, + }); + + // Setup our private state. More setup is done in the init + // callback that GObject calls, but we can't pass this data through + // to there (and we don't need it there directly) so this is here. + const priv: *Private = self.private(); + priv.* = .{ + .rt_app = rt_app, + .core_app = core_app, + .config = config_obj, + .winproto = wp, + .css_provider = css_provider, + .custom_css_providers = .empty, + .global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}), + .saved_language = saved_language, + .open_uri = .init(rt_app), + }; + + // Signals + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + // Trigger initial config changes + self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + + return self; + } + + /// Force deinitialize the application. + /// + /// Normally in a GObject lifecycle, this would be called by the + /// finalizer. But applications are never fully unreferenced so this + /// ensures that our memory is cleaned up properly. + pub fn deinit(self: *Self) void { + const alloc = self.allocator(); + const priv: *Private = self.private(); + priv.config.unref(); + priv.winproto.deinit(); + priv.open_uri.deinit(); + priv.global_shortcuts.unref(); + if (priv.saved_language) |language| alloc.free(language); + if (gdk.Display.getDefault()) |display| { + gtk.StyleContext.removeProviderForDisplay( + display, + priv.css_provider.as(gtk.StyleProvider), + ); + + for (priv.custom_css_providers.items) |provider| { + gtk.StyleContext.removeProviderForDisplay( + display, + provider.as(gtk.StyleProvider), + ); + } + } + priv.css_provider.unref(); + for (priv.custom_css_providers.items) |provider| provider.unref(); + priv.custom_css_providers.deinit(alloc); + } + + /// The global allocator that all other classes should use by + /// calling `Application.default().allocator()`. Zig code should prefer + /// this wherever possible so we get leak detection in debug/tests. + pub fn allocator(self: *Self) std.mem.Allocator { + return self.private().core_app.alloc; + } + + /// Get the original language that Ghostty was launched with. This returns a + /// pointer to internal memory so it must be copied by callers. + pub fn savedLanguage(self: *Self) ?[:0]const u8 { + return self.private().saved_language; + } + + /// Run the application. This is a replacement for `gio.Application.run` + /// because we want more tight control over our event loop so we can + /// integrate it with libghostty. + pub fn run(self: *Self) !void { + // Based on the actual `gio.Application.run` implementation: + // https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533 + + // Acquire the default context for the application + const ctx = glib.MainContext.default(); + if (glib.MainContext.acquire(ctx) == 0) return error.ContextAcquireFailed; + + // The final cleanup that is always required at the end of running. + defer { + // Ensure our timer source is removed + self.stopQuitTimer(); + + // Sync any remaining settings + gio.Settings.sync(); + + // Clear out the event loop, don't block. + while (glib.MainContext.iteration(ctx, 0) != 0) {} + + // Release the context so something else can use it. + defer glib.MainContext.release(ctx); + } + + // Register the application + var err_: ?*glib.Error = null; + if (self.as(gio.Application).register( + null, + &err_, + ) == 0) { + if (err_) |err| { + defer err.free(); + log.warn( + "error registering application: {s}", + .{err.f_message orelse "(unknown)"}, + ); + } + + return error.ApplicationRegisterFailed; + } + assert(err_ == null); + + // This just calls the `activate` signal but its part of the normal startup + // routine so we just call it, but only if the config allows it (this allows + // for launching Ghostty in the "background" without immediately opening + // a window). + // + // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 + const priv = self.private(); + { + // We need to scope any config access because once we run our + // event loop, this can change out from underneath us. + const config = priv.config.get(); + if (config.@"initial-window") self.as(gio.Application).activate(); + } + + // If we are NOT the primary instance, then we never want to run. + // This means that another instance of the GTK app is running. + if (self.as(gio.Application).getIsRemote() != 0) { + log.debug( + "application is remote, exiting run loop after activation", + .{}, + ); + return; + } + + // Tell systemd that we are ready. + systemd.notify.ready(); + + log.debug("entering runloop", .{}); + defer log.debug("exiting runloop", .{}); + priv.running = true; + while (priv.running) { + _ = glib.MainContext.iteration(ctx, 1); + + // Tick the core Ghostty terminal app + try priv.core_app.tick(priv.rt_app); + + // Check if we must quit based on the current state. + const must_quit = q: { + // If we are configured to always stay running, don't quit. + const config = priv.config.get(); + if (!config.@"quit-after-last-window-closed") break :q false; + + // If the quit timer has expired, quit. + if (priv.quit_timer == .expired) { + log.debug("must_quit due to quit timer expired", .{}); + break :q true; + } + + // If we have no windows attached to our app, also quit. + // We only do this if we don't have the closed delay set, + // because with the closed delay set we'll exit eventually. + if (config.@"quit-after-last-window-closed-delay" == null) { + if (priv.requested_window and @as( + ?*glib.List, + self.as(gtk.Application).getWindows(), + ) == null) { + log.debug("must_quit due to no app windows", .{}); + break :q true; + } + } + + // No quit conditions met + break :q false; + }; + + if (must_quit) { + // All must quit scenarios do not need confirmation. + // Furthermore, must quit scenarios may result in a situation + // where its unsafe to even access the app/surface memory + // since its in the process of being freed. We must simply + // begin our exit immediately. + self.quitNow(); + } + } + } + + /// Quit the application. This will start the process to stop the + /// run loop. It will not `posix.exit`. + pub fn quit(self: *Self) void { + const priv = self.private(); + + // If our run loop has already exited then we are done. + if (!priv.running) return; + + // If our core app doesn't need to confirm quit then we + // can exit immediately. + if (!priv.core_app.needsConfirmQuit()) { + self.quitNow(); + return; + } + + // Get the parent for our dialog + const parent: ?*gtk.Widget = parent: { + const list = gtk.Window.listToplevels(); + defer list.free(); + const focused = @as(?*glib.List, list.findCustom( + null, + findActiveWindow, + )) orelse { + // If we have an active surface then we should have + // a window available but in the rare case we don't we + // should exit so we don't crash. + break :parent null; + }; + break :parent @ptrCast(@alignCast(focused.f_data)); + }; + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.app); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Application, + handleCloseConfirmation, + self, + .{}, + ); + + // Show it + dialog.present(parent); + } + + fn quitNow(self: *Self) void { + // Get all our windows and destroy them, forcing them to free. + const list = gtk.Window.listToplevels(); + defer list.free(); + list.foreach(struct { + fn callback(data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void { + const ptr = data orelse return; + const window: *gtk.Window = @ptrCast(@alignCast(ptr)); + + // We only want to destroy our windows. These windows own + // every other type of window that is possible so this will + // trigger a proper shutdown sequence. + // + // We previously just destroyed ALL windows but this leads to + // a double-free with the fcitx ime, because it has a nested + // gtk.Window as a property that we don't own and it later + // tries to free on its own. I think this is probably a bug in + // the fcitx ime widget but still, we don't want a double free! + if (gobject.ext.isA(window, Window)) { + window.destroy(); + } + } + }.callback, null); + + // Trigger our runloop exit. + self.private().running = false; + } + + /// apprt API to perform an action. + pub fn performAction( + self: *Self, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) !bool { + switch (action) { + .close_tab => return Action.closeTab(target, value), + .close_window => return Action.closeWindow(target), + + .copy_title_to_clipboard => return Action.copyTitleToClipboard(target), + + .config_change => try Action.configChange( + self, + target, + value.config, + ), + + .desktop_notification => Action.desktopNotification(self, target, value), + + .equalize_splits => return Action.equalizeSplits(target), + + .goto_split => return Action.gotoSplit(target, value), + + .goto_window => return Action.gotoWindow(value), + + .goto_tab => return Action.gotoTab(target, value), + + .initial_size => return Action.initialSize(target, value), + + .inspector => return Action.controlInspector(target, value), + + .key_sequence => return Action.keySequence(target, value), + .key_table => return Action.keyTable(target, value), + + .mouse_over_link => Action.mouseOverLink(target, value), + .mouse_shape => Action.mouseShape(target, value), + .mouse_visibility => Action.mouseVisibility(target, value), + + .move_tab => return Action.moveTab(target, value), + + .new_split => return Action.newSplit(target, value), + + .new_tab => return Action.newTab(target), + + .new_window => try Action.newWindow( + self, + switch (target) { + .app => null, + .surface => |v| v, + }, + .none, + ), + + .open_config => return Action.openConfig(self), + + .open_url => Action.openUrl(self, value), + + .pwd => Action.pwd(target, value), + + .present_terminal => return Action.presentTerminal(target), + + .progress_report => return Action.progressReport(target, value), + + .prompt_title => return Action.promptTitle(target, value), + + .quit => self.quit(), + + .quit_timer => try Action.quitTimer(self, value), + + .reload_config => try Action.reloadConfig(self, target, value), + + .render => Action.render(target), + + .resize_split => return Action.resizeSplit(target, value), + + .ring_bell => Action.ringBell(target), + + .scrollbar => Action.scrollbar(target, value), + + .set_title => Action.setTitle(target, value), + .set_tab_title => return Action.setTabTitle(target, value), + + .show_child_exited => return Action.showChildExited(target, value), + + .show_gtk_inspector => Action.showGtkInspector(), + + .size_limit => return Action.sizeLimit(target, value), + + .toggle_maximize => Action.toggleMaximize(target), + .toggle_fullscreen => Action.toggleFullscreen(target), + .toggle_quick_terminal => return Action.toggleQuickTerminal(self), + .toggle_tab_overview => return Action.toggleTabOverview(target), + .toggle_window_decorations => return Action.toggleWindowDecorations(target), + .toggle_command_palette => return Action.toggleCommandPalette(target), + .toggle_split_zoom => return Action.toggleSplitZoom(target), + .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), + .command_finished => return Action.commandFinished(target, value), + .readonly => return Action.setReadonly(target, value), + + .start_search => Action.startSearch(target, value), + .end_search => Action.endSearch(target), + .search_total => Action.searchTotal(target, value), + .search_selected => Action.searchSelected(target, value), + + // Unimplemented + .secure_input, + .close_all_windows, + .float_window, + .toggle_visibility, + .toggle_background_opacity, + .cell_size, + .render_inspector, + .renderer_health, + .color_change, + .reset_window_size, + .check_for_updates, + .undo, + .redo, + => { + log.warn("unimplemented action={}", .{action}); + return false; + }, + } + + // Assume it was handled. The unhandled case must be explicit + // in the switch above. + return true; + } + + /// Returns the core app associated with this application. This is + /// not a reference-counted type so you should not store this. + pub fn core(self: *Self) *CoreApp { + return self.private().core_app; + } + + /// Returns the apprt application associated with this application. + pub fn rt(self: *Self) *ApprtApp { + return self.private().rt_app; + } + + /// Returns the app winproto implementation. + pub fn winproto(self: *Self) *winprotopkg.App { + return &self.private().winproto; + } + + /// Returns the open URI portal implementation. + pub fn openUri(self: *Self) *OpenURI { + return &self.private().open_uri; + } + + /// This will get called when there are no more open surfaces. + fn startQuitTimer(self: *Self) void { + const priv = self.private(); + const config = priv.config.get(); + + // Cancel any previous timer. + self.stopQuitTimer(); + + // This is a no-op unless we are configured to quit after last window is closed. + if (!config.@"quit-after-last-window-closed") return; + + // If a delay is configured, set a timeout function to quit after the delay. + if (config.@"quit-after-last-window-closed-delay") |v| { + priv.quit_timer = .{ + .active = glib.timeoutAdd( + v.asMilliseconds(), + handleQuitTimerExpired, + self, + ), + }; + } else { + // If no delay is configured, treat it as expired. + priv.quit_timer = .expired; + } + } + + /// This will get called when a new surface gets opened. + fn stopQuitTimer(self: *Self) void { + const priv = self.private(); + switch (priv.quit_timer) { + .off => {}, + .expired => priv.quit_timer = .off, + .active => |source| { + if (glib.Source.remove(source) == 0) { + log.warn( + "unable to remove quit timer source={d}", + .{source}, + ); + } + + priv.quit_timer = .off; + }, + } + } + + fn loadRuntimeCss(self: *Self) (Allocator.Error || std.Io.Writer.Error)!void { + const alloc = self.allocator(); + const priv: *Private = self.private(); + const config = priv.config.get(); + + var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048); + defer buf.deinit(); + + const writer = &buf.writer; + + // Load standard css first as it can override some of the user configured styling. + try loadRuntimeCss414(config, writer); + try loadRuntimeCss416(config, writer); + + const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background; + + try writer.print( + \\widget.unfocused-split {{ + \\ opacity: {d:.2}; + \\ background-color: rgb({d},{d},{d}); + \\}} + \\ + , .{ + 1.0 - config.@"unfocused-split-opacity", + unfocused_fill.r, + unfocused_fill.g, + unfocused_fill.b, + }); + + if (config.@"split-divider-color") |color| { + try writer.print( + \\.window .split paned > separator {{ + \\ color: rgb({[r]d},{[g]d},{[b]d}); + \\ background: rgb({[r]d},{[g]d},{[b]d}); + \\}} + \\ + , .{ + .r = color.r, + .g = color.g, + .b = color.b, + }); + } + + if (config.@"window-title-font-family") |font_family| { + try writer.print( + \\.window headerbar {{ + \\ font-family: "{[font_family]s}"; + \\}} + \\ + , .{ .font_family = font_family }); + } + + const contents = buf.written(); + + log.debug("runtime CSS is {d} bytes", .{contents.len}); + + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + // Clears any previously loaded CSS from this provider + priv.css_provider.loadFromBytes(bytes); + } + + /// Load runtime CSS for older than GTK 4.16 + fn loadRuntimeCss414( + config: *const CoreConfig, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (gtk_version.runtimeAtLeast(4, 16, 0)) return; + + const window_theme = config.@"window-theme"; + const headerbar_background = config.@"window-titlebar-background" orelse config.background; + const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; + + switch (window_theme) { + .ghostty => try writer.print( + \\windowhandle {{ + \\ background-color: rgb({d},{d},{d}); + \\ color: rgb({d},{d},{d}); + \\}} + \\windowhandle:backdrop {{ + \\ background-color: oklab(from rgb({d},{d},{d}) calc(l * 0.9) a b / alpha); + \\}} + \\ + , .{ + headerbar_background.r, + headerbar_background.g, + headerbar_background.b, + headerbar_foreground.r, + headerbar_foreground.g, + headerbar_foreground.b, + headerbar_background.r, + headerbar_background.g, + headerbar_background.b, + }), + else => {}, + } + } + + /// Load runtime for GTK 4.16 and newer + fn loadRuntimeCss416( + config: *const CoreConfig, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (gtk_version.runtimeUntil(4, 16, 0)) return; + + const window_theme = config.@"window-theme"; + const headerbar_background = config.@"window-titlebar-background" orelse config.background; + const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; + + try writer.writeAll( + \\/* + \\ * Child Exited Overlay + \\ */ + \\ + \\.child-exited.normal revealer widget { + \\ background-color: color-mix( + \\ in srgb, + \\ var(--success-bg-color), + \\ transparent 50% + \\ ); + \\} + \\ + \\.child-exited.abnormal revealer widget { + \\ background-color: color-mix( + \\ in srgb, + \\ var(--error-bg-color), + \\ transparent 50% + \\ ); + \\} + \\ + \\/* + \\ * Surface + \\ */ + \\ + \\.surface progressbar.error trough progress { + \\ background-color: color-mix( + \\ in srgb, + \\ var(--error-bg-color), + \\ transparent 50% + \\ ); + \\} + \\ + \\.surface .bell-overlay { + \\ border-color: color-mix( + \\ in srgb, + \\ var(--accent-color), + \\ transparent 50% + \\ ); + \\} + \\ + \\/* + \\ * Splits + \\ */ + \\ + \\.window .split paned > separator { + \\ background-color: color-mix( + \\ in srgb, + \\ var(--window-bg-color), + \\ transparent 0% + \\ ); + \\} + \\ + ); + + switch (window_theme) { + .ghostty => try writer.print( + \\:root {{ + \\ --ghostty-fg: rgb({d},{d},{d}); + \\ --ghostty-bg: rgb({d},{d},{d}); + \\ --headerbar-fg-color: var(--ghostty-fg); + \\ --headerbar-bg-color: var(--ghostty-bg); + \\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha); + \\ --overview-fg-color: var(--ghostty-fg); + \\ --overview-bg-color: var(--ghostty-bg); + \\ --popover-fg-color: var(--ghostty-fg); + \\ --popover-bg-color: var(--ghostty-bg); + \\ --window-fg-color: var(--ghostty-fg); + \\ --window-bg-color: var(--ghostty-bg); + \\}} + \\windowhandle {{ + \\ background-color: var(--headerbar-bg-color); + \\ color: var(--headerbar-fg-color); + \\}} + \\windowhandle:backdrop {{ + \\ background-color: var(--headerbar-backdrop-color); + \\}} + , .{ + headerbar_foreground.r, + headerbar_foreground.g, + headerbar_foreground.b, + headerbar_background.r, + headerbar_background.g, + headerbar_background.b, + }), + else => {}, + } + } + + fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void { + const priv: *Private = self.private(); + const alloc = self.allocator(); + const display = gdk.Display.getDefault() orelse { + log.warn("unable to get display", .{}); + return; + }; + + // unload the previously loaded style providers + for (priv.custom_css_providers.items) |provider| { + gtk.StyleContext.removeProviderForDisplay( + display, + provider.as(gtk.StyleProvider), + ); + provider.unref(); + } + priv.custom_css_providers.clearRetainingCapacity(); + + const config = priv.config.get(); + for (config.@"gtk-custom-css".value.items) |p| { + const path, const optional = switch (p) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + const file = std.fs.openFileAbsolute(path, .{}) catch |err| { + if (err != error.FileNotFound or !optional) { + log.warn( + "error opening gtk-custom-css file {s}: {}", + .{ path, err }, + ); + } + continue; + }; + defer file.close(); + + const css_file_size_limit = 5 * 1024 * 1024; // 5MB + + log.info("loading gtk-custom-css path={s}", .{path}); + const contents = file.readToEndAlloc( + alloc, + css_file_size_limit, + ) catch |err| switch (err) { + error.FileTooBig => { + log.warn("gtk-custom-css file {s} was larger than {Bi}", .{ path, css_file_size_limit }); + continue; + }, + else => |e| return e, + }; + defer alloc.free(contents); + + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + const css_provider = gtk.CssProvider.new(); + errdefer css_provider.unref(); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + try priv.custom_css_providers.append(alloc, css_provider); + + css_provider.loadFromBytes(bytes); + + gtk.StyleContext.addProviderForDisplay( + display, + css_provider.as(gtk.StyleProvider), + gtk.STYLE_PROVIDER_PRIORITY_USER, + ); + } + } + + fn syncActionAccelerators(self: *Self) void { + self.syncActionAccelerator("app.quit", .{ .quit = {} }); + self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); + self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); + self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); + self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); + self.syncActionAccelerator("win.close", .{ .close_window = {} }); + self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); + self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); + self.syncActionAccelerator("win.close-tab::this", .{ .close_tab = .this }); + self.syncActionAccelerator("tab.close::this", .{ .close_tab = .this }); + self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); + self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); + self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); + self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); + self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = .mixed }); + self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); + self.syncActionAccelerator("win.reset", .{ .reset = {} }); + self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); + self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); + self.syncActionAccelerator("split-tree.new-split::left", .{ .new_split = .left }); + self.syncActionAccelerator("split-tree.new-split::right", .{ .new_split = .right }); + self.syncActionAccelerator("split-tree.new-split::up", .{ .new_split = .up }); + self.syncActionAccelerator("split-tree.new-split::down", .{ .new_split = .down }); + } + + fn syncActionAccelerator( + self: *Self, + gtk_action: [:0]const u8, + action: input.Binding.Action, + ) void { + const gtk_app = self.as(gtk.Application); + + // Reset it initially + const zero = [_:null]?[*:0]const u8{}; + gtk_app.setAccelsForAction(gtk_action, &zero); + + const config = self.private().config.get(); + const trigger = config.keybind.set.getTrigger(action) orelse return; + var buf: [1024]u8 = undefined; + const accel = if (key.accelFromTrigger( + &buf, + trigger, + )) |accel_| + accel_ orelse return + else |err| switch (err) { + // This should really never, never happen. Its not critical enough + // to actually crash, but this is a bug somewhere. An accelerator + // for a trigger can't possibly be more than 1024 bytes. + error.WriteFailed => { + log.warn("accelerator somehow longer than 1024 bytes: {f}", .{trigger}); + return; + }, + }; + const accels = [_:null]?[*:0]const u8{accel}; + + gtk_app.setAccelsForAction(gtk_action, &accels); + } + + //--------------------------------------------------------------- + // Properties + + /// Returns the configuration for this application. + /// + /// The reference count is increased. + pub fn getConfig(self: *Self) *Config { + return self.private().config.ref(); + } + + /// Set the configuration for this application. The reference count + /// is increased on the new configuration and the old one is + /// unreferenced. + /// + /// If the config has errors this may show the config errors dialog. + fn setConfig(self: *Self, config: *Config) void { + const priv = self.private(); + priv.config.unref(); + priv.config = config.ref(); + self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + + // Show our errors if we have any + self.showConfigErrorsDialog(); + } + + fn propConfig( + _: *Application, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Sync our accelerators for menu items. + self.syncActionAccelerators(); + + // Load our runtime and custom CSS. If this fails then our window is + // just stuck with the old CSS but we don't want to fail the entire + // config change operation. + self.loadRuntimeCss() catch |err| switch (err) { + error.WriteFailed, error.OutOfMemory => log.warn( + "out of memory loading runtime CSS, no runtime CSS applied", + .{}, + ), + }; + self.loadCustomCss() catch |err| { + log.warn( + "failed to load custom CSS, no custom CSS applied, err={}", + .{err}, + ); + }; + } + + /// Log CSS parsing error + fn signalCssParsingError( + _: *gtk.CssProvider, + css_section: *gtk.CssSection, + err: *glib.Error, + _: *Self, + ) callconv(.c) void { + const location = css_section.toString(); + defer glib.free(location); + if (comptime gtk_version.atLeast(4, 16, 0)) bytes: { + const bytes = css_section.getBytes() orelse break :bytes; + var len: usize = undefined; + const ptr = bytes.getData(&len) orelse break :bytes; + const data = ptr[0..len]; + log.warn("css parsing failed at {s}: {s} {d} {s}\n{s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + data, + }); + return; + } + log.warn("css parsing failed at {s}: {s} {d} {s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + }); + } + + //--------------------------------------------------------------- + // Libghostty Callbacks + + pub fn wakeup(self: *Self) void { + _ = self; + glib.MainContext.wakeup(null); + } + + //--------------------------------------------------------------- + // Virtual Methods + + fn startup(self: *Self) callconv(.c) void { + log.debug("startup", .{}); + + gio.Application.virtual_methods.startup.call( + Class.parent, + self.as(Parent), + ); + + // Set ourselves as the default application. + gio.Application.setDefault(self.as(gio.Application)); + + // The D-Bus connection is only valid after GApplication startup. + self.openUri().setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // Setup our event loop + self.startupXev(); + + // Setup our style manager (light/dark mode) + self.startupStyleManager(); + + // Setup some signal handlers + self.startupSignals(); + + // Setup our action map + self.startupActionMap(); + + // Setup our global shortcuts + self.startupGlobalShortcuts(); + + // If we have any config diagnostics from loading, then we + // show the diagnostics dialog. We show this one as a general + // modal (not to any specific window) because we don't even + // know if the window will load. + self.showConfigErrorsDialog(); + } + + /// Configure libxev to use a specific backend. + /// + /// This must be called before any other xev APIs are used. + fn startupXev(self: *Self) void { + const priv = self.private(); + const config = priv.config.get(); + + // If our backend is auto then we have no setup to do. + if (config.@"async-backend" == .auto) return; + + // Setup our event loop backend to the preferred method + const result: bool = switch (config.@"async-backend") { + .auto => unreachable, + .epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false, + .io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false, + }; + + if (result) { + log.info( + "libxev manual backend={s}", + .{@tagName(xev.backend)}, + ); + } else { + log.warn( + "libxev manual backend failed, using default={s}", + .{@tagName(xev.backend)}, + ); + } + } + + /// Setup the style manager on startup. The primary task here is to + /// setup our initial light/dark mode based on the configuration and + /// setup listeners for changes to the style manager. + fn startupStyleManager(self: *Self) void { + const priv = self.private(); + const config = priv.config.get(); + + // Setup our initial light/dark + const style = self.as(adw.Application).getStyleManager(); + style.setColorScheme(switch (config.@"window-theme") { + .auto, .ghostty => auto: { + const lum = config.background.toTerminalRGB().perceivedLuminance(); + break :auto if (lum > 0.5) + .prefer_light + else + .prefer_dark; + }, + .system => .prefer_light, + .dark => .force_dark, + .light => .force_light, + }); + + // Setup color change notifications + _ = gobject.Object.signals.notify.connect( + style, + *Self, + handleStyleManagerDark, + self, + .{ .detail = "dark" }, + ); + + // Do an initial color scheme sync. This is idempotent and does nothing + // if our current theme matches what libghostty has so its safe to + // call. + handleStyleManagerDark(style, undefined, self); + } + + /// Setup signal handlers + fn startupSignals(self: *Self) void { + const priv = self.private(); + assert(priv.signal_source == null); + priv.signal_source = glib.unixSignalAdd( + std.posix.SIG.USR2, + handleSigusr2, + self, + ); + } + + /// Setup our action map. + fn startupActionMap(self: *Self) void { + const t_variant_type = glib.ext.VariantType.newFor(u64); + defer t_variant_type.free(); + + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + const actions = [_]ext.actions.Action(Self){ + .init("new-window", actionNewWindow, null), + .init("new-window-command", actionNewWindow, as_variant_type), + .init("open-config", actionOpenConfig, null), + .init("present-surface", actionPresentSurface, t_variant_type), + .init("quit", actionQuit, null), + .init("reload-config", actionReloadConfig, null), + }; + + ext.actions.add(Self, self, &actions); + } + + /// Setup our global shortcuts. + fn startupGlobalShortcuts(self: *Self) void { + const priv = self.private(); + + // On startup, our dbus connection should be available. + priv.global_shortcuts.setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // Setup a binding so that the shortcut config always matches the app. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + priv.global_shortcuts.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Setup the signal handler for global shortcut triggers + _ = GlobalShortcuts.signals.trigger.connect( + priv.global_shortcuts, + *Application, + globalShortcutTrigger, + self, + .{}, + ); + } + + fn activate(self: *Self) callconv(.c) void { + log.debug("activate", .{}); + + // Queue a new window + const priv = self.private(); + _ = priv.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); + + // Call the parent activate method. + gio.Application.virtual_methods.activate.call( + Class.parent, + self.as(Parent), + ); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.config_errors_dialog.get()) |diag| { + diag.close(); + diag.unref(); // strong ref from get() + } + priv.config_errors_dialog.set(null); + if (priv.signal_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove signal source", .{}); + } + priv.signal_source = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + self.deinit(); + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal Handlers + + /// SIGUSR2 signal handler via g_unix_signal_add + fn handleSigusr2(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse + return @intFromBool(glib.SOURCE_CONTINUE))); + + log.info("received SIGUSR2, reloading configuration", .{}); + Action.reloadConfig( + self, + .app, + .{}, + ) catch |err| { + // If we fail to reload the configuration, then we want the + // user to know it. For now we log but we should show another + // GUI. + log.warn("error reloading config: {}", .{err}); + }; + + return @intFromBool(glib.SOURCE_CONTINUE); + } + + fn handleCloseConfirmation( + _: *CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + self.quitNow(); + } + + fn handleQuitTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud)); + const priv = self.private(); + priv.quit_timer = .expired; + return 0; + } + + fn handleStyleManagerDark( + style: *adw.StyleManager, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const scheme: apprt.ColorScheme = if (style.getDark() == 0) + .light + else + .dark; + log.debug("style manager changed scheme={}", .{scheme}); + + const priv: *Private = self.private(); + const core_app = priv.core_app; + core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { + log.warn("error updating app color scheme err={}", .{err}); + }; + for (core_app.surfaces.items) |surface| { + surface.core().colorSchemeCallback(scheme) catch |err| { + log.warn( + "unable to tell surface about color scheme change err={}", + .{err}, + ); + }; + } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } + } + + fn handleReloadConfig( + _: *ConfigErrorsDialog, + self: *Self, + ) callconv(.c) void { + // We clear our dialog reference because its going to close + // after response handling and we don't want to reuse it. + const priv = self.private(); + priv.config_errors_dialog.set(null); + + // Reload our config as if the app reloaded. + Action.reloadConfig( + self, + .app, + .{}, + ) catch |err| { + // If we fail to reload the configuration, then we want the + // user to know it. For now we log but we should show another + // GUI. + log.warn("error reloading config: {}", .{err}); + }; + } + + /// Show the config errors dialog if the config on our application + /// has diagnostics. + fn showConfigErrorsDialog(self: *Self) void { + const priv = self.private(); + + // If we already have a dialog, just update the config. + if (priv.config_errors_dialog.get()) |diag| { + defer diag.unref(); // get gets a strong ref + + var value = gobject.ext.Value.newFrom(priv.config); + defer value.unset(); + gobject.Object.setProperty( + diag.as(gobject.Object), + "config", + &value, + ); + + if (!priv.config.hasDiagnostics()) { + diag.close(); + } else { + diag.present(null); + } + + return; + } + + // No diagnostics, do nothing. + if (!priv.config.hasDiagnostics()) return; + + // No dialog yet, initialize a new one. There's no need to unref + // here because the widget that it becomes a part of takes ownership. + const dialog: *ConfigErrorsDialog = .new(priv.config); + priv.config_errors_dialog.set(dialog); + + // Connect to the reload signal so we know to reload our config. + _ = ConfigErrorsDialog.signals.@"reload-config".connect( + dialog, + *Application, + handleReloadConfig, + self, + .{}, + ); + + // Show it + dialog.present(null); + } + + fn globalShortcutTrigger( + _: *GlobalShortcuts, + action: *const Binding.Action, + self: *Self, + ) callconv(.c) void { + self.core().performAllAction(self.rt(), action.*) catch |err| { + log.warn("failed to perform action={}", .{err}); + }; + } + + fn actionReloadConfig( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.core_app.performAction(self.rt(), .reload_config) catch |err| { + log.warn("error reloading config err={}", .{err}); + }; + } + + fn actionQuit( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.core_app.performAction(self.rt(), .quit) catch |err| { + log.warn("error quitting err={}", .{err}); + }; + } + + /// Handle `app.new-window` and `app.new-window-command` GTK actions + pub fn actionNewWindow( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + log.debug("received new window action", .{}); + + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + defer arena.deinit(); + + const alloc = arena.allocator(); + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + var args: std.ArrayList([:0]const u8) = .empty; + + overrides: { + // were we given a parameter? + const parameter = parameter_ orelse break :overrides; + + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + // ensure that the supplied parameter is an array of strings + if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + as_variant_type.peekString()[0..as_variant_type.getStringLength()], + }); + break :overrides; + } + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var it: glib.VariantIter = undefined; + _ = it.init(parameter); + + var e_seen: bool = false; + var i: usize = 0; + + while (it.nextValue()) |value| : (i += 1) { + defer value.unref(); + + // just to be sure + if (value.isOfType(s_variant_type) == 0) continue; + + var len: usize = undefined; + const buf = value.getString(&len); + const str = buf[0..len]; + + log.debug("new-window argument: {d} {s}", .{ i, str }); + + if (e_seen) { + const duplicated = alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + break :overrides; + }; + args.append(alloc, duplicated) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + break :overrides; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (lib.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + continue; + }; + command = cmd; + continue; + } + if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { + working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { + log.warn("unable to duplicate working directory: {t}", .{err}); + break :wd null; + }; + continue; + } + if (lib.cutPrefix(u8, str, "--title=")) |v| { + title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { + log.warn("unable to duplicate title: {t}", .{err}); + break :t null; + }; + continue; + } + } + } + + if (args.items.len > 0) { + command = .{ + .direct = args.items, + }; + } + + Action.newWindow(self, null, .{ + .command = command, + .working_directory = working_directory, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; + } + + pub fn actionOpenConfig( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = self.core().mailbox.push(.open_config, .forever); + } + + fn actionPresentSurface( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const parameter = parameter_ orelse return; + + const t = glib.ext.VariantType.newFor(u64); + defer glib.VariantType.free(t); + + // Make sure that we've received a u64 from the system. + if (glib.Variant.isOfType(parameter, t) == 0) { + return; + } + + // Convert the u64 to a core surface by using it as a surface ID. + // A value of zero means that there was no target surface for the + // notification so we don't focus any surface. + const surface_id = parameter.getUint64(); + if (surface_id == 0) return; + const surface = self.core().findSurfaceByID(surface_id) orelse return; + + _ = self.core().mailbox.push( + .{ + .surface_message = .{ + .surface = surface, + .message = .present_surface, + }, + }, + .forever, + ); + } + + //---------------------------------------------------------------- + // Boilerplate/Noise + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + // Register our compiled resources exactly once. + { + const c = @cImport({ + // generated header files + @cInclude("ghostty_resources.h"); + }); + if (c.ghostty_get_resource()) |ptr| { + gio.resourcesRegister(@ptrCast(@alignCast(ptr))); + } else { + // If we fail to load resources then things will + // probably look really bad but it shouldn't stop our + // app from loading. + log.warn("unable to load resources", .{}); + } + } + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + + // Virtual methods + gio.Application.virtual_methods.activate.implement(class, &activate); + gio.Application.virtual_methods.startup.implement(class, &startup); + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; + + pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void { + // Fallback to the minimal cross-platform way of opening a URL. + // This is always a safe fallback and enables for example Windows + // to open URLs (GTK on Windows via WSL is a thing). + internal_os.open( + self.allocator(), + kind, + url, + ) catch |err| log.warn("unable to open url: {}", .{err}); + } +}; + +/// All apprt action handlers +const Action = struct { + pub fn closeTab(target: apprt.Target, value: apprt.Action.Value(.close_tab)) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction( + "tab.close", + glib.ext.VariantType.stringFor([:0]const u8), + @as([*:0]const u8, @tagName(value)), + ) != 0; + }, + } + } + + pub fn closeWindow(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("win.close", null) != 0; + }, + } + } + + pub fn copyTitleToClipboard(target: apprt.Target) bool { + return switch (target) { + .app => false, + .surface => |v| v.rt_surface.gobj().copyTitleToClipboard(), + }; + } + + pub fn configChange( + self: *Application, + target: apprt.Target, + new_config: *const CoreConfig, + ) !void { + // Wrap our config in a GObject. This will clone it. + const alloc = self.allocator(); + const config_obj: *Config = try .new(alloc, new_config); + defer config_obj.unref(); + + switch (target) { + .surface => |core| core.rt_surface.surface.setConfig(config_obj), + .app => self.setConfig(config_obj), + } + } + + pub fn desktopNotification( + self: *Application, + target: apprt.Target, + n: apprt.action.DesktopNotification, + ) void { + switch (target) { + .app => {}, + .surface => |v| { + v.rt_surface.gobj().sendDesktopNotification(n.title, n.body); + return; + }, + } + + // Set a default title if we don't already have one + const t = switch (n.title.len) { + 0 => "Ghostty", + else => n.title, + }; + + const notification = gio.Notification.new(t); + defer notification.unref(); + notification.setBody(n.body); + + const icon = gio.ThemedIcon.new("com.mitchellh.ghostty"); + defer icon.unref(); + notification.setIcon(icon.as(gio.Icon)); + notification.setDefaultActionAndTargetValue( + "app.present-surface", + glib.Variant.newUint64(0), + ); + + // 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 = self.as(gio.Application); + gio_app.sendNotification(n.body, notification); + } + + pub fn equalizeSplits(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("equalize splits to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("split-tree.equalize", null) != 0; + }, + } + } + + pub fn gotoSplit( + target: apprt.Target, + to: apprt.action.GotoSplit, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + // Design note: we can't use widget actions here because + // we need to know whether there is a goto target for returning + // the proper perform result (boolean). + + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring goto_split", .{}); + return false; + }; + + return tree.goto(switch (to) { + .previous => .previous_wrapped, + .next => .next_wrapped, + .up => .{ .spatial = .up }, + .down => .{ .spatial = .down }, + .left => .{ .spatial = .left }, + .right => .{ .spatial = .right }, + }); + }, + } + } + + pub fn gotoTab( + target: apprt.Target, + tab: apprt.action.GotoTab, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + return window.selectTab(switch (tab) { + .previous => .previous, + .next => .next, + .last => .last, + else => .{ .n = @intCast(@intFromEnum(tab)) }, + }); + }, + } + } + + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; + + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + return false; + } + + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + + pub fn initialSize( + target: apprt.Target, + value: apprt.action.InitialSize, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + surface.setDefaultSize(.{ + .width = value.width, + .height = value.height, + }); + return true; + }, + } + } + + pub fn mouseOverLink( + target: apprt.Target, + value: apprt.action.MouseOverLink, + ) void { + switch (target) { + .app => log.warn("mouse over link to app is unexpected", .{}), + .surface => |surface| surface.rt_surface.gobj().setMouseHoverUrl( + if (value.url.len > 0) value.url else null, + ), + } + } + + pub fn mouseShape( + target: apprt.Target, + shape: terminal.MouseShape, + ) void { + switch (target) { + .app => log.warn("mouse shape to app is unexpected", .{}), + .surface => |surface| surface.rt_surface.gobj().setMouseShape(shape), + } + } + + pub fn mouseVisibility( + target: apprt.Target, + visibility: apprt.action.MouseVisibility, + ) void { + switch (target) { + .app => log.warn("mouse visibility to app is unexpected", .{}), + .surface => |surface| surface.rt_surface.gobj().setMouseHidden(switch (visibility) { + .visible => false, + .hidden => true, + }), + } + } + + pub fn moveTab( + target: apprt.Target, + value: apprt.action.MoveTab, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + return window.moveTab( + surface, + @intCast(value.amount), + ); + }, + } + } + + pub fn newSplit( + target: apprt.Target, + direction: apprt.action.SplitDirection, + ) bool { + switch (target) { + .app => { + log.warn("new split to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + + return surface.as(gtk.Widget).activateAction( + "split-tree.new-split", + "&s", + @tagName(direction).ptr, + ) != 0; + }, + } + } + + pub fn newTab(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("new tab to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // Get the window ancestor of the surface. Surfaces shouldn't + // be aware they might be in windows but at the app level we + // can do this. + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + window.newTab(core); + return true; + }, + } + } + + pub fn newWindow( + self: *Application, + parent: ?*CoreSurface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) !void { + // Note that we've requested a window at least once. This is used + // to trigger quit on no windows. Note I'm not sure if this is REALLY + // necessary, but I don't want to risk a bug where on a slow machine + // or something we quit immediately after starting up because there + // was a delay in the event loop before we created a Window. + self.private().requested_window = true; + + const win = Window.new(self, .{ + .title = overrides.title, + }); + initAndShowWindow( + self, + win, + parent, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); + } + + fn initAndShowWindow( + self: *Application, + win: *Window, + parent: ?*CoreSurface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) void { + // Setup a binding so that whenever our config changes so does the + // window. There's never a time when the window config should be out + // of sync with the application config. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + win.as(gobject.Object), + "config", + .{}, + ); + + // Create a new tab with window context (first tab in new window) + win.newTabForWindow(parent, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); + + // Estimate the initial window size before presenting so the window + // manager can position it correctly. + if (win.getActiveSurface()) |surface| { + surface.estimateInitialSize(); + if (surface.getDefaultSize()) |size| { + win.as(gtk.Window).setDefaultSize( + @intCast(size.width), + @intCast(size.height), + ); + } + } + + // Show the window + gtk.Window.present(win.as(gtk.Window)); + } + + pub fn openConfig(self: *Application) bool { + // Get the config file path + const alloc = self.allocator(); + const path = configpkg.edit.openPath(alloc) catch |err| { + log.warn("error getting config file path: {}", .{err}); + return false; + }; + defer alloc.free(path); + + // Open it using openURL. "path" isn't actually a URL but + // at the time of writing that works just fine for GTK. + openUrl(self, .{ .kind = .text, .url = path }); + return true; + } + + pub fn openUrl( + self: *Application, + value: apprt.action.OpenUrl, + ) void { + if (std.mem.startsWith(u8, value.url, "/")) { + self.openUrlFallback(value.kind, value.url); + return; + } + if (std.mem.startsWith(u8, value.url, "file://")) { + self.openUrlFallback(value.kind, value.url); + return; + } + + self.openUri().start(value) catch |err| { + log.err("unable to open uri err={}", .{err}); + self.openUrlFallback(value.kind, value.url); + return; + }; + } + + pub fn pwd( + target: apprt.Target, + value: apprt.action.Pwd, + ) void { + switch (target) { + .app => log.warn("pwd to app is unexpected", .{}), + .surface => |surface| surface.rt_surface.gobj().setPwd(value.pwd), + } + } + + pub fn quitTimer( + self: *Application, + mode: apprt.action.QuitTimer, + ) !void { + switch (mode) { + .start => self.startQuitTimer(), + .stop => self.stopQuitTimer(), + } + } + + pub fn presentTerminal( + target: apprt.Target, + ) bool { + return switch (target) { + .app => false, + .surface => |v| surface: { + v.rt_surface.surface.present(); + break :surface true; + }, + }; + } + + pub fn progressReport( + target: apprt.Target, + value: terminal.osc.Command.ProgressReport, + ) bool { + return switch (target) { + .app => false, + .surface => |v| surface: { + v.rt_surface.surface.setProgressReport(value); + break :surface true; + }, + }; + } + + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + switch (target) { + .app => return false, + .surface => |v| { + const surface = v.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring prompt_tab_title", .{}); + return false; + }; + tab.promptTabTitle(); + return true; + }, + } + }, + } + } + + /// Reload the configuration for the application and propagate it + /// across the entire application and all terminals. + pub fn reloadConfig( + self: *Application, + target: apprt.Target, + opts: apprt.action.ReloadConfig, + ) !void { + // Tell systemd that reloading has started. + systemd.notify.reloading(); + + // When we exit this function tell systemd that reloading has finished. + defer systemd.notify.ready(); + + // Get our config object. + const config: *Config = config: { + // Soft-reloading applies conditional logic to the existing loaded + // config so we return that as-is (but take a reference). + if (opts.soft) { + break :config self.private().config.ref(); + } + + // Hard reload, load a new config completely. + const alloc = self.allocator(); + var config = try CoreConfig.load(alloc); + defer config.deinit(); + break :config try .new(alloc, &config); + }; + defer config.unref(); + + // Update the proper target. This will trigger a `config_change` + // apprt action which will propagate the config properly to our + // property system. + switch (target) { + .app => try self.core().updateConfig( + self.rt(), + config.get(), + ), + .surface => |core| try core.updateConfig(config.get()), + } + } + + pub fn render(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.redraw(), + } + } + + pub fn resizeSplit( + target: apprt.Target, + value: apprt.action.ResizeSplit, + ) bool { + switch (target) { + .app => { + log.warn("resize_split to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring resize_split", .{}); + return false; + }; + + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.getIsSplit()) return false; + + return tree.resize( + switch (value.direction) { + .up => .up, + .down => .down, + .left => .left, + .right => .right, + }, + value.amount, + ) catch |err| switch (err) { + error.OutOfMemory => { + log.warn("unable to resize split, out of memory", .{}); + return false; + }, + }; + }, + } + } + + pub fn ringBell(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setBellRinging(true), + } + } + + pub fn scrollbar( + target: apprt.Target, + value: apprt.Action.Value(.scrollbar), + ) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setScrollbar(value), + } + } + + pub fn startSearch(target: apprt.Target, value: apprt.action.StartSearch) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(true, value.needle), + } + } + + pub fn endSearch(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchActive(false, ""), + } + } + + pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchTotal(value.total), + } + } + + pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setSearchSelected(value.selected), + } + } + + pub fn setTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) void { + switch (target) { + .app => log.warn("set_title to app is unexpected", .{}), + .surface => |surface| surface.rt_surface.gobj().setTitle(value.title), + } + } + + pub fn setTabTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring set_tab_title", .{}); + return false; + }; + tab.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + + pub fn showChildExited( + target: apprt.Target, + value: apprt.surface.Message.ChildExited, + ) bool { + return switch (target) { + .app => false, + .surface => |v| v.rt_surface.surface.childExited(value), + }; + } + + pub fn showGtkInspector() void { + gtk.Window.setInteractiveDebugging(@intFromBool(true)); + } + + pub fn sizeLimit( + target: apprt.Target, + value: apprt.action.SizeLimit, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + // Note: we ignore the max size currently because we have + // no mechanism to enforce it. + const surface = core.rt_surface.surface; + surface.setMinSize(.{ + .width = value.min_width, + .height = value.min_height, + }); + + return true; + }, + } + } + + pub fn toggleFullscreen(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.toggleFullscreen(), + } + } + + pub fn toggleQuickTerminal(self: *Application) bool { + // If we already have a quick terminal window, we just toggle the + // visibility of it. + if (getQuickTerminalWindow()) |win| { + win.toggleVisibility(); + return true; + } + + // If we don't support quick terminals then we do nothing. + const priv = self.private(); + if (!priv.winproto.supportsQuickTerminal()) return false; + + // Create our new window as a quick terminal + const win = gobject.ext.newInstance(Window, .{ + .application = self, + .@"quick-terminal" = true, + }); + assert(win.isQuickTerminal()); + initAndShowWindow(self, win, null, .none); + return true; + } + + pub fn toggleSplitZoom(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("toggle_split_zoom to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // TODO: pass surface ID when we have that + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring toggle_split_zoom", .{}); + return false; + }; + + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.getIsSplit()) return false; + + return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; + }, + } + } + + pub fn showOnScreenKeyboard(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("show_on_screen_keyboard to app is unexpected", .{}); + return false; + }, + // NOTE: Even though `activateOsk` takes a gdk.Event, it's currently + // unused by all implementations of `activateOsk` as of GTK 4.18. + // The commit that introduced the method (ce6aa73c) clarifies that + // the event *may* be used by other IM backends, but for Linux desktop + // environments this doesn't matter. + .surface => |v| return v.rt_surface.surface.showOnScreenKeyboard(null), + } + } + + fn getQuickTerminalWindow() ?*Window { + // Find a quick terminal window. + const list = gtk.Window.listToplevels(); + defer list.free(); + if (ext.listFind(gtk.Window, list, struct { + fn find(gtk_win: *gtk.Window) bool { + const win = gobject.ext.cast( + Window, + gtk_win, + ) orelse return false; + return win.isQuickTerminal(); + } + }.find)) |w| return gobject.ext.cast( + Window, + w, + ).?; + + return null; + } + + pub fn toggleMaximize(target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.toggleMaximize(), + } + } + + pub fn toggleTabOverview(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + window.toggleTabOverview(); + return true; + }, + } + } + + pub fn toggleWindowDecorations(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring toggle_window_decorations", .{}); + return false; + }; + + window.toggleWindowDecorations(); + return true; + }, + } + } + + pub fn toggleCommandPalette(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().toggleCommandPalette(); + }, + } + } + + pub fn controlInspector(target: apprt.Target, value: apprt.Action.Value(.inspector)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().controlInspector(value); + }, + } + } + + pub fn commandFinished(target: apprt.Target, value: apprt.Action.Value(.command_finished)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().commandFinished(value); + }, + } + } + + pub fn setReadonly(target: apprt.Target, value: apprt.Action.Value(.readonly)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().setReadonly(value); + }, + } + } + + pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool { + switch (target) { + .app => { + log.warn("key_sequence action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keySequenceAction(value) catch |err| { + log.warn("error handling key_sequence action: {}", .{err}); + }; + return true; + }, + } + } + + pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool { + switch (target) { + .app => { + log.warn("key_table action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keyTableAction(value) catch |err| { + log.warn("error handling key_table action: {}", .{err}); + }; + return true; + }, + } + } +}; + +/// This sets various GTK-related environment variables as necessary +/// given the runtime environment or configuration. +/// +/// This must be called BEFORE GTK initialization. +fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { + assert(gtk.isInitialized() == 0); + + var gdk_debug: struct { + /// output OpenGL debug information + opengl: bool = false, + /// disable GLES, Ghostty can't use GLES + @"gl-disable-gles": bool = false, + // GTK's new renderer can cause blurry font when using fractional scaling. + @"gl-no-fractional": bool = false, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + @"vulkan-disable": bool = false, + } = .{ + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", + }; + + var gdk_disable: struct { + @"gles-api": bool = false, + /// current gtk implementation for color management is not good enough. + /// see: https://bugs.kde.org/show_bug.cgi?id=495647 + /// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864 + @"color-mgmt": bool = true, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + vulkan: bool = false, + } = .{}; + + environment: { + if (gtk_version.runtimeAtLeast(4, 18, 0)) { + gdk_disable.@"color-mgmt" = false; + } + + if (gtk_version.runtimeAtLeast(4, 16, 0)) { + // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. + // For the remainder of "why" see the 4.14 comment below. + gdk_disable.@"gles-api" = true; + gdk_disable.vulkan = true; + break :environment; + } + if (gtk_version.runtimeAtLeast(4, 14, 0)) { + // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. + // Older versions of GTK do not support these values so it is safe + // to always set this. Forwards versions are uncertain so we'll have + // to reassess... + // + // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 + gdk_debug.@"gl-disable-gles" = true; + gdk_debug.@"vulkan-disable" = true; + + if (gtk_version.runtimeUntil(4, 17, 5)) { + // Removed at GTK v4.17.5 + gdk_debug.@"gl-no-fractional" = true; + } + break :environment; + } + + // Versions prior to 4.14 are a bit of an unknown for Ghostty. It + // is an environment that isn't tested well and we don't have a + // good understanding of what we may need to do. + gdk_debug.@"vulkan-disable" = true; + } + + { + var buf: [1024]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| { + if (@field(gdk_debug, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + const value = fmt.getWritten(); + log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); + } + + { + var buf: [1024]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| { + if (@field(gdk_disable, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + const value = fmt.getWritten(); + log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); + } +} + +fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int { + const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1))); + + // Confusingly, `isActive` returns 1 when active, + // but we want to return 0 to indicate equality. + // Abusing integers to be enums and booleans is a terrible idea, C. + return if (window.isActive() != 0) 0 else -1; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig new file mode 100644 index 00000000..d44d38a3 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -0,0 +1,352 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const apprt = @import("../../../apprt.zig"); +const gresource = @import("../build/gresource.zig"); +const i18n = @import("../../../os/main.zig").i18n; +const adw_version = @import("../adw_version.zig"); +const Common = @import("../class.zig").Common; +const Dialog = @import("dialog.zig").Dialog; + +const log = std.log.scoped(.gtk_ghostty_clipboard_confirmation); + +/// Whether we're able to have the remember switch +const can_remember = adw_version.supportsSwitchRow(); + +pub const ClipboardConfirmationDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = Dialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyClipboardConfirmationDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"can-remember" = struct { + pub const name = "can-remember"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "can_remember", + ), + }, + ); + }; + + pub const request = struct { + pub const name = "request"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*apprt.ClipboardRequest, + .{ + .accessor = C.privateBoxedFieldAccessor("request"), + }, + ); + }; + + pub const @"clipboard-contents" = struct { + pub const name = "clipboard-contents"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.TextBuffer, + .{ + .accessor = C.privateObjFieldAccessor("clipboard_contents"), + }, + ); + }; + + pub const blur = struct { + pub const name = "blur"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "blur", + ), + }, + ); + }; + }; + + pub const signals = struct { + pub const deny = struct { + pub const name = "deny"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{bool}, + void, + ); + }; + + pub const confirm = struct { + pub const name = "confirm"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{bool}, + void, + ); + }; + }; + + const Private = struct { + /// The request that this dialog is for. + request: ?*apprt.ClipboardRequest = null, + + /// The clipboard contents being read/written. + clipboard_contents: ?*gtk.TextBuffer = null, + + /// Whether the contents should be blurred. + blur: bool = false, + + /// Whether the user can remember the choice. + can_remember: bool = false, + + // Template bindings + text_view_scroll: *gtk.ScrolledWindow, + text_view: *gtk.TextView, + reveal_button: *gtk.Button, + hide_button: *gtk.Button, + remember_choice: if (can_remember) *adw.SwitchRow else void, + + pub var offset: c_int = 0; + }; + + pub fn new() *Self { + return gobject.ext.newInstance(Self, .{}); + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Trigger initial values + self.propBlur(undefined, null); + self.propRequest(undefined, null); + } + + pub fn present(self: *Self, parent: ?*gtk.Widget) void { + self.as(Dialog).present(parent); + } + + /// Get the clipboard request without copying. + pub fn getRequest(self: *Self) ?*apprt.ClipboardRequest { + return self.private().request; + } + + /// Get the clipboard contents without copying. + pub fn getClipboardContents(self: *Self) ?*gtk.TextBuffer { + return self.private().clipboard_contents; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propBlur( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (priv.blur) { + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); + priv.text_view.as(gtk.Widget).addCssClass("blurred"); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } else { + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); + priv.text_view.as(gtk.Widget).removeCssClass("blurred"); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } + } + + fn propRequest( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const req = priv.request orelse return; + switch (req.*) { + .osc_52_write => { + self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); + self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); + }, + .osc_52_read => { + self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); + self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); + }, + .paste => { + self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste")); + self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.")); + }, + } + } + + fn revealButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const priv = self.private(); + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); + priv.text_view.as(gtk.Widget).removeCssClass("blurred"); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(true)); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); + } + + fn hideButtonClicked(_: *gtk.Button, self: *Self) callconv(.c) void { + const priv = self.private(); + priv.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); + priv.text_view.as(gtk.Widget).addCssClass("blurred"); + priv.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); + priv.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn response( + self: *Self, + response_id: [*:0]const u8, + ) callconv(.c) void { + const remember: bool = if (comptime can_remember) remember: { + const priv = self.private(); + break :remember priv.remember_choice.getActive() != 0; + } else false; + + if (std.mem.orderZ(u8, response_id, "cancel") == .eq) { + signals.deny.impl.emit( + self, + null, + .{remember}, + null, + ); + } else if (std.mem.orderZ(u8, response_id, "ok") == .eq) { + signals.confirm.impl.emit( + self, + null, + .{remember}, + null, + ); + } + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.clipboard_contents) |v| { + v.unref(); + priv.clipboard_contents = 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 priv = self.private(); + if (priv.request) |v| { + glib.ext.destroy(v); + priv.request = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + if (comptime adw_version.atLeast(1, 4, 0)) + comptime gresource.blueprint(.{ + .major = 1, + .minor = 4, + .name = "clipboard-confirmation-dialog", + }) + else + comptime gresource.blueprint(.{ + .major = 1, + .minor = 0, + .name = "clipboard-confirmation-dialog", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("text_view_scroll", .{}); + class.bindTemplateChildPrivate("text_view", .{}); + class.bindTemplateChildPrivate("hide_button", .{}); + class.bindTemplateChildPrivate("reveal_button", .{}); + if (comptime can_remember) { + class.bindTemplateChildPrivate("remember_choice", .{}); + } + + // Template Callbacks + class.bindTemplateCallback("reveal_clicked", &revealButtonClicked); + class.bindTemplateCallback("hide_clicked", &hideButtonClicked); + class.bindTemplateCallback("notify_blur", &propBlur); + class.bindTemplateCallback("notify_request", &propRequest); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.blur.impl, + properties.@"can-remember".impl, + properties.@"clipboard-contents".impl, + properties.request.impl, + }); + + // Signals + signals.confirm.impl.register(.{}); + signals.deny.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + Dialog.virtual_methods.response.implement(class, &response); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig new file mode 100644 index 00000000..5919f9c9 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/close_confirmation_dialog.zig @@ -0,0 +1,204 @@ +const std = @import("std"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const i18n = @import("../../../os/main.zig").i18n; +const Common = @import("../class.zig").Common; +const Dialog = @import("dialog.zig").Dialog; + +const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog); + +pub const CloseConfirmationDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = Dialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCloseConfirmationDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const target = struct { + pub const name = "target"; + const impl = gobject.ext.defineProperty( + name, + Self, + Target, + .{ + .default = .app, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "target", + ), + }, + ); + }; + }; + + pub const signals = struct { + pub const @"close-request" = struct { + pub const name = "close-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + pub const cancel = struct { + pub const name = "cancel"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + target: Target, + pub var offset: c_int = 0; + }; + + pub fn new(target: Target) *Self { + return gobject.ext.newInstance(Self, .{ + .target = target, + }); + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn present(self: *Self, parent: ?*gtk.Widget) void { + // Setup our title/body text. + const priv = self.private(); + self.as(Dialog.Parent).setHeading(priv.target.title()); + self.as(Dialog.Parent).setBody(priv.target.body()); + + // Show it + self.as(Dialog).present(parent); + } + + pub fn close(self: *Self) void { + self.as(Dialog).close(); + } + + fn response( + self: *Self, + response_id: [*:0]const u8, + ) callconv(.c) void { + if (std.mem.orderZ(u8, response_id, "close") == .eq) { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + } else { + signals.cancel.impl.emit( + self, + null, + .{}, + null, + ); + } + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + 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(Dialog); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "close-confirmation-dialog", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.target.impl, + }); + + // Signals + signals.@"close-request".impl.register(.{}); + signals.cancel.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + Dialog.virtual_methods.response.implement(class, &response); + } + + pub const as = C.Class.as; + }; +}; + +/// The target of a close dialog. +/// +/// This is here so that we can consolidate all logic related to +/// prompting the user and closing windows/tabs/surfaces/etc. +/// together into one struct that is the sole source of truth. +pub const Target = enum(c_int) { + app, + tab, + window, + surface, + + pub fn title(self: Target) [*:0]const u8 { + return switch (self) { + .app => i18n._("Quit Ghostty?"), + .tab => i18n._("Close Tab?"), + .window => i18n._("Close Window?"), + .surface => i18n._("Close Split?"), + }; + } + + pub fn body(self: Target) [*:0]const u8 { + return switch (self) { + .app => i18n._("All terminal sessions will be terminated."), + .tab => i18n._("All terminal sessions in this tab will be terminated."), + .window => i18n._("All terminal sessions in this window will be terminated."), + .surface => i18n._("The currently running process in this split will be terminated."), + }; + } + + pub const getGObjectType = gobject.ext.defineEnum( + Target, + .{ .name = "GhosttyCloseConfirmationDialogTarget" }, + ); +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig new file mode 100644 index 00000000..6101e82e --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig @@ -0,0 +1,789 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Window = @import("window.zig").Window; +const Surface = @import("surface.zig").Surface; +const Tab = @import("tab.zig").Tab; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_command_palette); + +pub const CommandPalette = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommandPalette", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when a command from the command palette is activated. The + /// action contains pointers to allocated data so if a receiver of this + /// signal needs to keep the action around it will need to clone the + /// action or there may be use-after-free errors. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const input.Binding.Action}, + void, + ); + }; + }; + + const Private = struct { + /// The configuration that this command palette is using. + config: ?*Config = null, + + /// The dialog object containing the palette UI. + dialog: *adw.Dialog, + + /// The search input text field. + search: *gtk.SearchEntry, + + /// The view containing each result row. + view: *gtk.ListView, + + /// The model that provides filtered data for the view to display. + model: *gtk.SingleSelection, + + /// The list that serves as the data source of the model. + /// This is where all command data is ultimately stored. + source: *gio.ListStore, + + pub var offset: c_int = 0; + }; + + /// Create a new instance of the command palette. The caller will own a + /// reference to the object. + pub fn new() *Self { + const self = gobject.ext.newInstance(Self, .{}); + + // Sink ourselves so that we aren't floating anymore. We'll unref + // ourselves when the palette is closed or an action is activated. + _ = self.refSink(); + + // Bump the ref so that the caller has a reference. + return self.ref(); + } + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Listen for any changes to our config. + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + propConfig, + null, + .{ + .detail = "config", + }, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.source.removeAll(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propConfig(self: *CommandPalette, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + const priv = self.private(); + + const config = priv.config orelse { + log.warn("command palette does not have a config!", .{}); + return; + }; + + // Clear existing binds + priv.source.removeAll(); + + const alloc = Application.default().allocator(); + var commands: std.ArrayList(*Command) = .{}; + defer { + for (commands.items) |cmd| cmd.unref(); + commands.deinit(alloc); + } + + self.collectJumpCommands(config, &commands) catch |err| { + log.warn("failed to collect jump commands: {}", .{err}); + }; + + self.collectRegularCommands(config, &commands, alloc); + + // Sort commands + std.mem.sort(*Command, commands.items, {}, struct { + fn lessThan(_: void, a: *Command, b: *Command) bool { + return compareCommands(a, b); + } + }.lessThan); + + for (commands.items) |cmd| { + const cmd_ref = cmd.as(gobject.Object); + priv.source.append(cmd_ref); + } + } + + /// Collect regular commands from configuration, filtering out unsupported actions. + fn collectRegularCommands( + self: *CommandPalette, + config: *Config, + commands: *std.ArrayList(*Command), + alloc: std.mem.Allocator, + ) void { + _ = self; + const cfg = config.get(); + + for (cfg.@"command-palette-entry".value.items) |command| { + // Filter out actions that are not implemented or don't make sense + // for GTK. + if (!isActionSupportedOnGtk(command.action)) continue; + + const cmd = Command.new(config, command) catch |err| { + log.warn("failed to create command: {}", .{err}); + continue; + }; + errdefer cmd.unref(); + + commands.append(alloc, cmd) catch |err| { + log.warn("failed to add command to list: {}", .{err}); + continue; + }; + } + } + + /// Check if an action is supported on GTK. + fn isActionSupportedOnGtk(action: input.Binding.Action) bool { + return switch (action) { + .close_all_windows, + .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, + => false, + + else => true, + }; + } + + /// Collect jump commands for all surfaces across all windows. + fn collectJumpCommands( + self: *CommandPalette, + config: *Config, + commands: *std.ArrayList(*Command), + ) !void { + _ = self; + const app = Application.default(); + const alloc = app.allocator(); + + // Get all surfaces from the core app + const core_app = app.core(); + for (core_app.surfaces.items) |apprt_surface| { + const surface = apprt_surface.gobj(); + const cmd = Command.newJump(config, surface); + errdefer cmd.unref(); + try commands.append(alloc, cmd); + } + } + + /// Compare two commands for sorting. + /// Sorts alphabetically by title (case-insensitive), with colon normalization + /// so "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker. + fn compareCommands(a: *Command, b: *Command) bool { + const a_title = a.propGetTitle() orelse return false; + const b_title = b.propGetTitle() orelse return true; + + // Compare case-insensitively with colon normalization + for (0..@min(a_title.len, b_title.len)) |i| { + // Get characters, replacing ':' with '\t' + const a_char = if (a_title[i] == ':') '\t' else a_title[i]; + const b_char = if (b_title[i] == ':') '\t' else b_title[i]; + + const a_lower = std.ascii.toLower(a_char); + const b_lower = std.ascii.toLower(b_char); + + if (a_lower != b_lower) { + return a_lower < b_lower; + } + } + + // If one title is a prefix of the other, shorter one comes first + if (a_title.len != b_title.len) { + return a_title.len < b_title.len; + } + + // Titles are equal - use sort_key as tie-breaker if both are jump commands + const a_sort_key = switch (a.private().data) { + .regular => return false, + .jump => |*ja| ja.sort_key, + }; + const b_sort_key = switch (b.private().data) { + .regular => return false, + .jump => |*jb| jb.sort_key, + }; + + return a_sort_key < b_sort_key; + } + + fn close(self: *CommandPalette) void { + const priv = self.private(); + _ = priv.dialog.close(); + } + + fn dialogClosed(_: *adw.Dialog, self: *CommandPalette) callconv(.c) void { + self.unref(); + } + + fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // ESC was pressed - close the palette + self.close(); + } + + fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // If Enter is pressed, activate the selected entry + const priv = self.private(); + self.activated(priv.model.getSelected()); + } + + fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { + self.activated(pos); + } + + //--------------------------------------------------------------- + + /// Show or hide the command palette dialog. If the dialog is shown it will + /// be modal over the given window. + pub fn toggle(self: *CommandPalette, window: *Window) void { + const priv = self.private(); + + // If the dialog has been shown, close it. + if (priv.dialog.as(gtk.Widget).getRealized() != 0) { + self.close(); + return; + } + + // Show the dialog + priv.dialog.present(window.as(gtk.Widget)); + + // Focus on the search bar when opening the dialog + _ = priv.search.as(gtk.Widget).grabFocus(); + } + + /// Helper function to send a signal containing the action that should be + /// performed. + fn activated(self: *CommandPalette, pos: c_uint) void { + const priv = self.private(); + + // Use priv.model and not priv.source here to use the list of *visible* results + const object_ = priv.model.as(gio.ListModel).getObject(pos); + defer if (object_) |object| object.unref(); + + // Close before running the action in order to avoid being replaced by + // another dialog (such as the change title dialog). If that occurs then + // the command palette dialog won't be counted as having closed properly + // and cannot receive focus when reopened. + self.close(); + + const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return; + + // Handle jump commands differently + if (cmd.isJump()) { + const surface = cmd.getJumpSurface() orelse return; + defer surface.unref(); + surface.present(); + return; + } + + // Regular command - emit trigger signal + const action = cmd.getAction() orelse return; + + // Signal that an action has been selected. Signals are synchronous + // so we shouldn't need to worry about cloning the action. + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + 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(Command); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "command-palette", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("dialog", .{}); + class.bindTemplateChildPrivate("search", .{}); + class.bindTemplateChildPrivate("view", .{}); + class.bindTemplateChildPrivate("model", .{}); + class.bindTemplateChildPrivate("source", .{}); + + // Template Callbacks + class.bindTemplateCallback("closed", &dialogClosed); + class.bindTemplateCallback("notify_config", &propConfig); + class.bindTemplateCallback("search_stopped", &searchStopped); + class.bindTemplateCallback("search_activated", &searchActivated); + class.bindTemplateCallback("row_activated", &rowActivated); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; + +/// Object that wraps around a command. +/// +/// As GTK list models only accept objects that are within the GObject hierarchy, +/// we have to construct a wrapper to be easily consumed by the list model. +const Command = extern struct { + pub const Self = @This(); + pub const Parent = gobject.Object; + parent: Parent, + + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommand", + .instanceInit = &init, + .classInit = Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const action_key = struct { + pub const name = "action-key"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetActionKey, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const action = struct { + pub const name = "action"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetAction, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const title = struct { + pub const name = "title"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetTitle, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const description = struct { + pub const name = "description"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetDescription, + .getter_transfer = .none, + }, + ), + }, + ); + }; + }; + + pub const Private = struct { + config: ?*Config = null, + arena: ArenaAllocator, + data: CommandData, + + pub var offset: c_int = 0; + + pub const CommandData = union(enum) { + regular: RegularData, + jump: JumpData, + }; + + pub const RegularData = struct { + command: input.Command, + action: ?[:0]const u8 = null, + action_key: ?[:0]const u8 = null, + }; + + pub const JumpData = struct { + surface: WeakRef(Surface) = .empty, + title: ?[:0]const u8 = null, + description: ?[:0]const u8 = null, + sort_key: usize, + }; + }; + + pub fn new(config: *Config, command: input.Command) Allocator.Error!*Self { + const self = gobject.ext.newInstance(Self, .{ + .config = config, + }); + errdefer self.unref(); + + const priv = self.private(); + const cloned = try command.clone(priv.arena.allocator()); + + priv.data = .{ + .regular = .{ + .command = cloned, + }, + }; + + return self; + } + + /// Create a new jump command that focuses a specific surface. + pub fn newJump(config: *Config, surface: *Surface) *Self { + const self = gobject.ext.newInstance(Self, .{ + .config = config, + }); + + const priv = self.private(); + priv.data = .{ + .jump = .{ + // TODO: Replace with surface id whenever Ghostty adds one + .sort_key = @intFromPtr(surface), + }, + }; + priv.data.jump.surface.set(surface); + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + // NOTE: we do not watch for changes to the config here as the command + // palette will destroy and recreate this object if/when the config + // changes. + + const priv = self.private(); + priv.arena = .init(Application.default().allocator()); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + switch (priv.data) { + .regular => {}, + .jump => |*j| { + j.surface.set(null); + }, + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.arena.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + + fn propGetActionKey(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + const regular = switch (priv.data) { + .regular => |*r| r, + .jump => return null, + }; + + if (regular.action_key) |action_key| return action_key; + + regular.action_key = std.fmt.allocPrintSentinel( + priv.arena.allocator(), + "{f}", + .{regular.command.action}, + 0, + ) catch null; + + return regular.action_key; + } + + fn propGetAction(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + const regular = switch (priv.data) { + .regular => |*r| r, + .jump => return null, + }; + + if (regular.action) |action| return action; + + const cfg = if (priv.config) |config| config.get() else return null; + const keybinds = cfg.keybind.set; + + const alloc = priv.arena.allocator(); + + regular.action = action: { + var buf: [64]u8 = undefined; + const trigger = keybinds.getTrigger(regular.command.action) orelse break :action null; + const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null; + break :action alloc.dupeZ(u8, accel) catch return null; + }; + + return regular.action; + } + + fn propGetTitle(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + switch (priv.data) { + .regular => |*r| return r.command.title, + .jump => |*j| { + if (j.title) |title| return title; + + const surface = j.surface.get() orelse return null; + defer surface.unref(); + + const alloc = priv.arena.allocator(); + const effective_title = surface.getEffectiveTitle() orelse "Untitled"; + + j.title = std.fmt.allocPrintSentinel( + alloc, + "Focus: {s}", + .{effective_title}, + 0, + ) catch null; + + return j.title; + }, + } + } + + fn propGetDescription(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + switch (priv.data) { + .regular => |*r| return r.command.description, + .jump => |*j| { + if (j.description) |desc| return desc; + + const surface = j.surface.get() orelse return null; + defer surface.unref(); + + const alloc = priv.arena.allocator(); + const title = surface.getEffectiveTitle() orelse "Untitled"; + const pwd = surface.getPwd(); + + if (pwd) |p| { + if (std.mem.indexOf(u8, title, p) == null) { + j.description = alloc.dupeZ(u8, p) catch null; + } + } + + return j.description; + }, + } + } + + //--------------------------------------------------------------- + + /// Return a copy of the action. Callers must ensure that they do not use + /// the action beyond the lifetime of this object because it has internally + /// allocated data that will be freed when this object is. + pub fn getAction(self: *Self) ?input.Binding.Action { + const priv = self.private(); + return switch (priv.data) { + .regular => |*r| r.command.action, + .jump => null, + }; + } + + /// Check if this is a jump command. + pub fn isJump(self: *Self) bool { + const priv = self.private(); + return priv.data == .jump; + } + + /// Get the jump surface. Returns a strong reference that the caller + /// must unref when done, or null if the surface has been destroyed. + pub fn getJumpSurface(self: *Self) ?*Surface { + const priv = self.private(); + return switch (priv.data) { + .regular => null, + .jump => |*j| j.surface.get(), + }; + } + + //--------------------------------------------------------------- + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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.registerProperties(class, &.{ + properties.config.impl, + properties.action_key.impl, + properties.action.impl, + properties.title.impl, + properties.description.impl, + }); + + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/config.zig b/vendor/libghostty-vt/src/apprt/gtk/class/config.zig new file mode 100644 index 00000000..9a705d35 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/config.zig @@ -0,0 +1,175 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../../config.zig"); +const CoreConfig = configpkg.Config; + +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_config); + +/// Wraps a `Ghostty.Config` object in a GObject so it can be reference +/// counted. When this object is freed, the underlying config is also freed. +/// +/// It is highly recommended to NOT take a reference to this object, +/// since configuration takes up a lot of memory (relatively). Instead, +/// receivers of this should usually create a `DerivedConfig` struct from +/// this, copy any memory they require, and own that structure instead. +/// +/// This can also expose helpers to access configuration in ways that +/// may be more ergonomic to GTK primitives. +pub const Config = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyConfig", + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"diagnostics-buffer" = gobject.ext.defineProperty( + "diagnostics-buffer", + Self, + ?*gtk.TextBuffer, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*gtk.TextBuffer, + .{ + .getter = Self.diagnosticsBuffer, + .getter_transfer = .full, + }, + ), + }, + ); + + pub const @"has-diagnostics" = gobject.ext.defineProperty( + "has-diagnostics", + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = Self.hasDiagnostics, + }, + ), + }, + ); + }; + + const Private = struct { + config: CoreConfig, + + pub var offset: c_int = 0; + }; + + /// Create a new GhosttyConfig from a loaded configuration. + /// + /// This clones the given configuration, so it is safe for the + /// caller to free the original configuration after this call. + pub fn new(alloc: Allocator, config: *const CoreConfig) Allocator.Error!*Self { + const self = gobject.ext.newInstance(Self, .{}); + errdefer self.unref(); + + const priv = self.private(); + priv.config = try config.clone(alloc); + + return self; + } + + /// Get the wrapped configuration. It's unsafe to store this or access + /// it in any way that may live beyond the lifetime of this object. + pub fn get(self: *Self) *const CoreConfig { + return &self.private().config; + } + + /// Get the mutable configuration. This is usually NOT recommended + /// because any changes to the config won't be propagated to anyone + /// with a reference to this object. If you know what you're doing, then + /// you can use this. + pub fn getMut(self: *Self) *CoreConfig { + return &self.private().config; + } + + /// Returns whether this configuration has any diagnostics. + pub fn hasDiagnostics(self: *Self) bool { + const config = self.get(); + return !config._diagnostics.empty(); + } + + /// Reads the diagnostics of this configuration as a TextBuffer, + /// or returns null if there are no diagnostics. + pub fn diagnosticsBuffer(self: *Self) ?*gtk.TextBuffer { + const config = self.get(); + if (config._diagnostics.empty()) return null; + + const text_buf: *gtk.TextBuffer = .new(null); + errdefer text_buf.unref(); + + var buf: [4095:0]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + for (config._diagnostics.items()) |diag| { + writer.end = 0; + diag.format(&writer) catch |err| { + log.warn( + "error writing diagnostic to buffer err={}", + .{err}, + ); + continue; + }; + + text_buf.insertAtCursor(&buf, @intCast(writer.end)); + text_buf.insertAtCursor("\n", 1); + } + + return text_buf; + } + + fn finalize(self: *Self) callconv(.c) void { + self.private().config.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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.Object.virtual_methods.finalize.implement(class, &finalize); + gobject.ext.registerProperties(class, &.{ + properties.@"diagnostics-buffer", + properties.@"has-diagnostics", + }); + } + }; +}; + +// This test verifies our memory management works as expected. Since +// we use the testing allocator any leaks are detected. +test "GhosttyConfig" { + const testing = std.testing; + const alloc = testing.allocator; + var config: CoreConfig = try .default(alloc); + defer config.deinit(); + const obj: *Config = try .new(alloc, &config); + obj.unref(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig new file mode 100644 index 00000000..46d5fe62 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/config_errors_dialog.zig @@ -0,0 +1,161 @@ +const std = @import("std"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Dialog = @import("dialog.zig").Dialog; + +const log = std.log.scoped(.gtk_ghostty_config_errors_dialog); + +pub const ConfigErrorsDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = Dialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyConfigErrorsDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = gobject.ext.defineProperty( + "config", + Self, + ?*Config, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Config, + .{ + .getter = Self.getConfig, + .setter = Self.setConfig, + }, + ), + }, + ); + }; + + pub const signals = struct { + pub const @"reload-config" = struct { + pub const name = "reload-config"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + config: ?*Config, + pub var offset: c_int = 0; + }; + + pub fn new(config: *Config) *Self { + return gobject.ext.newInstance(Self, .{ + .config = config, + }); + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn present(self: *Self, parent: ?*gtk.Widget) void { + self.as(Dialog).present(parent); + } + + pub fn close(self: *Self) void { + self.as(Dialog).close(); + } + + fn response( + self: *Self, + response_id: [*:0]const u8, + ) callconv(.c) void { + if (std.mem.orderZ(u8, response_id, "reload") != .eq) return; + signals.@"reload-config".impl.emit( + self, + null, + .{}, + null, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn getConfig(self: *Self) ?*Config { + return self.private().config; + } + + fn setConfig(self: *Self, config: ?*Config) void { + const priv = self.private(); + if (priv.config) |old| old.unref(); + + // We don't need to increase the reference count because + // the property setter handles it (uses GValue.get vs. take) + priv.config = config; + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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(Dialog); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "config-errors-dialog", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config, + }); + + // Signals + signals.@"reload-config".impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + Dialog.virtual_methods.response.implement(class, &response); + } + + pub fn as(class: *Class, comptime T: type) *T { + return gobject.ext.as(T, class); + } + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig b/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig new file mode 100644 index 00000000..0ad32033 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/debug_warning.zig @@ -0,0 +1,50 @@ +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const adw_version = @import("../adw_version.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +/// Debug warning banner. It will be based on adw.Banner if we're using Adwaita +/// 1.3 or newer. Otherwise it will use a gtk.Label. +pub const DebugWarning = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = if (adw_version.supportsBanner()) adw.Bin else gtk.Box; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyDebugWarning", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + }); + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + const C = Common(Self, null); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = if (adw_version.supportsBanner()) 3 else 2, + .name = "debug-warning", + }), + ); + } + + pub const as = C.Class.as; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig new file mode 100644 index 00000000..5bc3cdfa --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/dialog.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const adw_version = @import("../adw_version.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_dialog); + +/// Dialog is a simple abstraction over the `adw.AlertDialog` and +/// `adw.MessageDialog` widgets, chosen at comptime based on the linked +/// Adwaita version. +/// +/// Once we drop support for Adwaita <= 1.2, this can be fully removed +/// and we can use `adw.AlertDialog` directly. +pub const Dialog = extern struct { + const Self = @This(); + parent_instance: Parent, + + pub const Parent = if (adw_version.supportsDialogs()) + adw.AlertDialog + else + adw.MessageDialog; + + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyDialog", + .classInit = &Class.init, + .parent_class = &Class.parent, + }); + + pub const virtual_methods = struct { + /// Forwarded from parent so subclasses can reference this + /// directly. This will make it easier to remove Dialog in the future. + pub const response = Parent.virtual_methods.response; + }; + + pub fn present(self: *Self, parent: ?*gtk.Widget) void { + switch (Parent) { + adw.AlertDialog => self.as(adw.Dialog).present(parent), + + adw.MessageDialog => { + // Reset the previous parent window + self.as(gtk.Window).setTransientFor(null); + + // We need to get the window for the parent in order + // to set the transient-for property on the MessageDialog. + if (parent) |widget| parent: { + const root = gtk.Widget.getRoot(widget) orelse break :parent; + const window = gobject.ext.cast( + gtk.Window, + root, + ) orelse break :parent; + self.as(gtk.Window).setTransientFor(window); + } + + self.as(gtk.Window).present(); + }, + + else => comptime unreachable, + } + } + + pub fn close(self: *Self) void { + switch (Parent) { + adw.AlertDialog => self.as(adw.Dialog).forceClose(), + adw.MessageDialog => self.as(gtk.Window).close(), + else => comptime unreachable, + } + } + + const C = Common(Self, null); + pub const as = C.as; + pub const ref = C.ref; + 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 { + _ = class; + } + + pub fn as(class: *Class, comptime T: type) *T { + return gobject.ext.as(T, class); + } + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig new file mode 100644 index 00000000..cf0f31a6 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig @@ -0,0 +1,633 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const Binding = @import("../../../input.zig").Binding; +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_global_shortcuts); + +pub const GlobalShortcuts = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyGlobalShortcuts", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"dbus-connection" = struct { + pub const name = "dbus-connection"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gio.DBusConnection, + .{ + .accessor = C.privateObjFieldAccessor("dbus_connection"), + }, + ); + }; + }; + + const Private = struct { + /// The configuration that this is using. + config: ?*Config = null, + + /// The dbus connection. + dbus_connection: ?*gio.DBusConnection = null, + + /// An arena allocator that is present for each refresh. + arena: ?std.heap.ArenaAllocator = null, + + /// A mapping from a unique ID to an action. + /// Currently the unique ID is simply the serialized representation of the + /// trigger that was used for the action as triggers are unique in the keymap, + /// but this may change in the future. + map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + + /// The handle of the current global shortcuts portal session, + /// as a D-Bus object path. + handle: ?[:0]const u8 = null, + + /// The D-Bus signal subscription for the response signal on requests. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + response_subscription: c_uint = 0, + + /// The D-Bus signal subscription for the keybind activate signal. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + activate_subscription: c_uint = 0, + + pub var offset: c_int = 0; + }; + + pub const signals = struct { + /// Emitted whenever a global shortcut is triggered. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const Binding.Action}, + void, + ); + }; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + } + + fn close(self: *Self) void { + const priv = self.private(); + const dbus = priv.dbus_connection orelse return; + + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } + + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } + + if (priv.arena) |*arena| { + arena.deinit(); + priv.arena = null; + priv.map = .{}; // Uses arena memory + } + } + + fn refresh(self: *Self) Allocator.Error!void { + // Always close our previous state first. + self.close(); + + const priv = self.private(); + + // We need configuration to proceed. + const config = if (priv.config) |v| v.get() else return; + + // Setup our new arena that we'll use for memory allocations. + assert(priv.arena == null); + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Our map starts out empty again. We don't need to worry about + // memory because its part of the arena we clear. + priv.map = .{}; + errdefer priv.map = .{}; + + // Update map + var trigger_buf: [1024]u8 = undefined; + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { + .leader => continue, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), + }; + if (!leaf.flags.global) continue; + + // We only allow global keybinds that map to exactly a single + // action for now. TODO: remove this restriction + const actions = leaf.actionsSlice(); + if (actions.len != 1) continue; + + const trigger = if (key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + )) |shortcut_| + shortcut_ orelse continue + else |err| switch (err) { + // If there isn't space to translate the trigger, then our + // buffer might be too small (but 1024 is insane!). In any case + // we don't want to stop registering globals. + error.WriteFailed => { + log.warn( + "buffer too small to translate trigger, ignoring={f}", + .{entry.key_ptr.*}, + ); + continue; + }, + }; + + try priv.map.put( + alloc, + try alloc.dupeZ(u8, trigger), + actions[0], + ); + } + + // Store our arena + priv.arena = arena; + + // Create our session if we have global shortcuts. + if (priv.map.count() > 0) try self.request(.create_session); + } + + const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const priv = shortcuts.private(); + const handle = priv.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = priv.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{f}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.warn( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + const priv = shortcuts.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + priv.handle = alloc.dupeZ(u8, std.mem.span(handle.?)) catch { + log.warn("out of memory: failed to clone session handle", .{}); + return; + }; + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + assert(priv.activate_subscription == 0); + priv.activate_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.warn("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } + }; + + /// Submit a request to the global shortcuts portal. + fn request( + self: *Self, + comptime method: Method, + ) Allocator.Error!void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.warn("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_cb: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + const priv = self_cb.private(); + + // Unsubscribe from the response signal + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_cb, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.warn("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + const priv = self.private(); + const dbus = priv.dbus_connection.?; + + assert(priv.response_subscription == 0); + priv.response_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); + } + + /// Get the XDG portal request path for the current Ghostty instance. + /// + /// If this sounds like nonsense, see `request` for an explanation as to + /// why we need to do this. + /// + /// Precondition: dbus connection exists, arena setup + fn getRequestPath(self: *Self, token: [:0]const u8) Allocator.Error![:0]const u8 { + const priv = self.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; + } + + //--------------------------------------------------------------- + // Property Handlers + + pub fn setDbusConnection( + self: *Self, + dbus_connection: ?*gio.DBusConnection, + ) void { + const priv = self.private(); + + // If we have a prior dbus connection we need to close our prior + // registrations first. + if (priv.dbus_connection) |v| { + self.close(); + v.unref(); + priv.dbus_connection = null; + } + + priv.dbus_connection = null; + if (dbus_connection) |v| { + v.ref(); // Weird this doesn't return self + priv.dbus_connection = v; + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + self.as(gobject.Object).notifyByPspec(properties.@"dbus-connection".impl.param_spec); + } + + fn propConfig( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + // Since we drop references here we may lose access to things like + // dbus connections, so we need to close all our connections right + // away instead of in finalize. + self.close(); + + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + if (priv.dbus_connection) |v| { + v.unref(); + priv.dbus_connection = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.@"dbus-connection".impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; + +const Token = [16]u8; + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig new file mode 100644 index 00000000..0ef753a8 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig @@ -0,0 +1,551 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; + +const cimgui = @import("dcimgui"); +const gl = @import("opengl"); +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); + +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_imgui_widget); + +/// A widget for embedding a Dear ImGui application. +/// +/// It'd be a lot cleaner to use inheritance here but zig-gobject doesn't +/// currently have a way to define virtual methods, so we have to use +/// composition and signals instead. +pub const ImguiWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyImguiWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct {}; + + pub const signals = struct {}; + + pub const virtual_methods = struct { + /// This virtual method will be called to allow the Dear ImGui + /// application to do one-time setup of the context. The correct context + /// will be current when the virtual method is called. + pub const setup = C.defineVirtualMethod("setup"); + + /// This virtual method will be called at each frame to allow the Dear + /// ImGui application to draw the application. The correct context will + /// be current when the virtual method is called. + pub const render = C.defineVirtualMethod("render"); + }; + + const Private = struct { + /// GL area where we display the Dear ImGui application. + gl_area: *gtk.GLArea, + + /// GTK input method context + im_context: *gtk.IMMulticontext, + + /// Dear ImGui context. We create a context per widget so that we can + /// have multiple active imgui views in the same application. + ig_context: ?*cimgui.c.ImGuiContext = null, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + /// Tick callback ID for timed updates. + tick_callback_id: c_uint = 0, + + /// Last render time for throttling to 30 FPS. + last_render_time: ?std.time.Instant = null, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + /// This should be called anytime the underlying data for the UI changes + /// so that the UI can be refreshed. + pub fn queueRender(self: *ImguiWidget) void { + const priv = self.private(); + priv.gl_area.queueRender(); + } + + //--------------------------------------------------------------- + // Public wrappers for virtual methods + + /// This virtual method will be called to allow the Dear ImGui application + /// to do one-time setup of the context. The correct context will be current + /// when the virtual method is called. + pub fn setup(self: *Self) callconv(.c) void { + const class = self.getClass() orelse return; + virtual_methods.setup.call(class, self, .{}); + } + + /// This virtual method will be called at each frame to allow the Dear ImGui + /// application to draw the application. The correct context will be current + /// when the virtual method is called. + pub fn render(self: *Self) callconv(.c) void { + const class = self.getClass() orelse return; + virtual_methods.render.call(class, self, .{}); + } + + //--------------------------------------------------------------- + // Private Methods + + /// Set our imgui context to be current, or return an error. This must be + /// called before any Dear ImGui API calls so that they're made against + /// the proper context. + fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void { + const priv = self.private(); + const ig_context = priv.ig_context orelse { + log.warn("Dear ImGui context not initialized", .{}); + return error.ContextNotInitialized; + }; + cimgui.c.ImGui_SetCurrentContext(ig_context); + } + + /// Initialize the frame. Expects that the context is already current. + fn newFrame(self: *Self) void { + const priv = self.private(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + // Determine our delta time + const now = std.time.Instant.now() catch unreachable; + io.DeltaTime = if (priv.instant) |prev| delta: { + const since_ns: f64 = @floatFromInt(now.since(prev)); + const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s); + const since_s: f32 = @floatCast(since_ns / ns_per_s); + break :delta @max(0.00001, since_s); + } else (1.0 / 60.0); + + priv.instant = now; + } + + /// Handle key press/release events. + fn keyEvent( + self: *ImguiWidget, + action: input.Action, + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + ) bool { + self.queueRender(); + + self.setCurrentContext() catch return false; + + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + + const mods = key.translateMods(gtk_mods); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); + + // If our keyval has a key, then we send that key event + if (key.keyFromKeyval(keyval)) |inputkey| { + if (inputkey.imguiKey()) |imgui_key| { + cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press); + } + } + + // Try to process the event as text + if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| { + const priv = self.private(); + _ = priv.im_context.as(gtk.IMContext).filterKeypress(event); + } + + return true; + } + + /// Translate a GTK mouse button to a Dear ImGui mouse button. + fn translateMouseButton(button: c_uint) ?c_int { + return switch (button) { + 1 => cimgui.c.ImGuiMouseButton_Left, + 2 => cimgui.c.ImGuiMouseButton_Middle, + 3 => cimgui.c.ImGuiMouseButton_Right, + else => null, + }; + } + + /// Get the scale factor that the display is operating at. + fn getScaleFactor(self: *Self) f64 { + const priv = self.private(); + return @floatFromInt(priv.gl_area.as(gtk.Widget).getScaleFactor()); + } + + //--------------------------------------------------------------- + // Properties + + //--------------------------------------------------------------- + // Signal Handlers + + fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void { + const priv = self.private(); + assert(priv.ig_context == null); + + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + return; + } + + priv.ig_context = cimgui.c.ImGui_CreateContext(null) orelse { + log.warn("unable to initialize Dear ImGui context", .{}); + return; + }; + self.setCurrentContext() catch return; + + // Setup some basic config + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + io.BackendPlatformName = "ghostty_gtk"; + + // Realize means that our OpenGL context is ready, so we can now + // initialize the ImgUI OpenGL backend for our context. + _ = cimgui.ImGui_ImplOpenGL3_Init(null); + + // Call the virtual method to setup the UI. + self.setup(); + + // Add a tick callback to drive timed updates via the frame clock. + priv.tick_callback_id = self.as(gtk.Widget).addTickCallback( + tickCallback, + null, + null, + ); + } + + /// Handle a request to unrealize the GLArea + fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { + const priv = self.private(); + assert(priv.ig_context != null); + + // Remove the tick callback if it was registered. + if (priv.tick_callback_id != 0) { + self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id); + priv.tick_callback_id = 0; + } + + // Unrealize is not guaranteed to be called with a current GL context, + // so we make it current for ImGui cleanup. + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + return; + } + + self.setCurrentContext() catch return; + cimgui.ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup(); + cimgui.c.ImGui_DestroyContext(priv.ig_context); + priv.ig_context = null; + } + + /// Handle a request to resize the GLArea + fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + const scale_factor = area.as(gtk.Widget).getScaleFactor(); + + // Our display size is always unscaled. We'll do the scaling in the + // style instead. This creates crisper looking fonts. + io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; + io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; + + // Setup a new style and scale it appropriately. We must use the + // ImGuiStyle constructor to get proper default values (e.g., + // CurveTessellationTol) rather than zero-initialized values. + var style: cimgui.c.ImGuiStyle = undefined; + cimgui.ext.ImGuiStyle_ImGuiStyle(&style); + cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatFromInt(scale_factor)); + const active_style = cimgui.c.ImGui_GetStyle(); + active_style.* = style; + } + + /// Handle a request to render the contents of our GLArea + fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int { + self.setCurrentContext() catch return @intFromBool(false); + + // Update last render time for tick callback throttling. + const priv = self.private(); + priv.last_render_time = std.time.Instant.now() catch null; + + // Setup our frame. We render twice because some ImGui behaviors + // take multiple renders to process. I don't know how to make this + // more efficient. + for (0..2) |_| { + cimgui.ImGui_ImplOpenGL3_NewFrame(); + self.newFrame(); + cimgui.c.ImGui_NewFrame(); + + // Call the virtual method to draw the UI. + self.render(); + + // Render + cimgui.c.ImGui_Render(); + } + + // OpenGL final render + gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); + gl.clear(gl.c.GL_COLOR_BUFFER_BIT); + cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData()); + + return @intFromBool(true); + } + + fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, true); + self.queueRender(); + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, false); + self.queueRender(); + } + + fn ecKeyPressed( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) 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, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) callconv(.c) void { + _ = self.keyEvent( + .release, + ec_key, + keyval, + keycode, + gtk_mods, + ); + } + + fn ecMousePressed( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); + } + } + + fn ecMouseReleased( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); + } + } + + fn ecMouseMotion( + _: *gtk.EventControllerMotion, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + const scale_factor = self.getScaleFactor(); + cimgui.c.ImGuiIO_AddMousePosEvent( + io, + @floatCast(x * scale_factor), + @floatCast(y * scale_factor), + ); + } + + fn ecMouseScroll( + _: *gtk.EventControllerScroll, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) c_int { + self.queueRender(); + self.setCurrentContext() catch return @intFromBool(false); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddMouseWheelEvent( + io, + @floatCast(x), + @floatCast(-y), + ); + return @intFromBool(true); + } + + fn imCommit( + _: *gtk.IMMulticontext, + bytes: [*:0]u8, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); + } + + /// Tick callback for timed updates. This drives periodic redraws. + /// Redraws are limited to 30 FPS max since our imgui widgets don't + /// usually need higher frame rates than that. + fn tickCallback( + widget: *gtk.Widget, + _: *gdk.FrameClock, + _: ?*anyopaque, + ) callconv(.c) c_int { + const self: *Self = gobject.ext.cast(Self, widget) orelse return 0; + const priv = self.private(); + + const now = std.time.Instant.now() catch { + self.queueRender(); + return 1; + }; + + // Throttle to 30 FPS (~33ms between frames) + const frame_time_ns: u64 = std.time.ns_per_s / 30; + const should_render = if (priv.last_render_time) |last| + now.since(last) >= frame_time_ns + else + true; + + if (should_render) self.queueRender(); + + return 1; // Continue the tick callback + } + + //--------------------------------------------------------------- + // Default virtual method handlers + + /// Default setup function. Does nothing but log a warning. + fn defaultSetup(_: *Self) callconv(.c) void { + log.warn("default Dear ImGui setup called, this is a bug.", .{}); + } + + /// Default render function. Does nothing but log a warning. + fn defaultRender(_: *Self) callconv(.c) void { + log.warn("default Dear ImGui render called, this is a bug.", .{}); + } + + 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; + pub const getClass = C.getClass; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + + /// Function pointers for virtual methods. + setup: ?*const fn (*Self) callconv(.c) void, + render: ?*const fn (*Self) callconv(.c) void, + + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "imgui-widget", + }), + ); + + // Initialize our virtual methods with default functions. + class.setup = defaultSetup; + class.render = defaultRender; + + // Bindings + class.bindTemplateChildPrivate("gl_area", .{}); + class.bindTemplateChildPrivate("im_context", .{}); + + // Template Callbacks + class.bindTemplateCallback("realize", &glAreaRealize); + class.bindTemplateCallback("unrealize", &glAreaUnrealize); + class.bindTemplateCallback("resize", &glAreaResize); + class.bindTemplateCallback("render", &glAreaRender); + class.bindTemplateCallback("focus_enter", &ecFocusEnter); + class.bindTemplateCallback("focus_leave", &ecFocusLeave); + class.bindTemplateCallback("key_pressed", &ecKeyPressed); + class.bindTemplateCallback("key_released", &ecKeyReleased); + class.bindTemplateCallback("mouse_pressed", &ecMousePressed); + class.bindTemplateCallback("mouse_released", &ecMouseReleased); + class.bindTemplateCallback("mouse_motion", &ecMouseMotion); + class.bindTemplateCallback("scroll", &ecMouseScroll); + class.bindTemplateCallback("im_commit", &imCommit); + + // Signals + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig new file mode 100644 index 00000000..80ac7fc3 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_widget.zig @@ -0,0 +1,245 @@ +const std = @import("std"); + +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Inspector = @import("../../../inspector/Inspector.zig"); + +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const ImguiWidget = @import("imgui_widget.zig").ImguiWidget; + +const log = std.log.scoped(.gtk_ghostty_inspector_widget); + +/// Widget for displaying the Ghostty inspector. +pub const InspectorWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = ImguiWidget; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to. This is NOT referenced. + /// We attach a weak notify to the object. + surface: ?*Surface = null, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + // Clear our surface so it deactivates the inspector. + self.setSurface(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + /// Called to do initial setup of the UI. + fn imguiSetup( + _: *Self, + ) callconv(.c) void { + Inspector.setup(); + } + + /// Called for every frame to draw the UI. + fn imguiRender( + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.surface orelse return; + const core_surface = surface.core() orelse return; + const inspector = core_surface.inspector orelse return; + inspector.render(core_surface); + } + + //--------------------------------------------------------------- + // Public methods + + /// Queue a render of the Dear ImGui widget. + pub fn queueRender(self: *Self) void { + self.as(ImguiWidget).queueRender(); + } + + //--------------------------------------------------------------- + // Properties + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { + const priv = self.private(); + + // Do nothing if we're not changing the value. + if (surface_ == priv.surface) return; + + // Setup our notification to happen at the end because we're + // changing values no matter what. + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + // Deactivate the inspector on the old surface if it exists + // and set our value to null. + if (priv.surface) |old| old: { + priv.surface = null; + + // Remove our weak ref + old.as(gobject.Object).weakUnref( + surfaceWeakNotify, + self, + ); + + // Deactivate the inspector + const core_surface = old.core() orelse break :old; + core_surface.deactivateInspector(); + } + + // Activate the inspector on the new surface. + const surface = surface_ orelse return; + const core_surface = surface.core() orelse return; + core_surface.activateInspector() catch |err| { + log.warn("failed to activate inspector err={}", .{err}); + return; + }; + + // We use a weak reference on surface to determine if the surface + // was closed while our inspector was active. + surface.as(gobject.Object).weakRef( + surfaceWeakNotify, + self, + ); + + // Store our surface. We don't need to ref this because we setup + // the weak notify above. + priv.surface = surface; + + self.queueRender(); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn surfaceWeakNotify( + ud: ?*anyopaque, + surface: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + + // The weak notify docs call out that we can specifically use the + // pointer values for comparison, but the objects themselves are unsafe. + if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return; + + // According to weak notify docs, "surface" is in the "dispose" state. + // Our surface doesn't clear the core surface until the "finalize" + // state so we should be able to safely access it here. We need to + // be really careful though. + const old = priv.surface orelse return; + const core_surface = old.core() orelse return; + core_surface.deactivateInspector(); + priv.surface = null; + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + // Note: in the future we should probably show some content on our + // window to note that the surface went away in case our embedding + // widget doesn't close itself. As I type this, our window closes + // immediately when the surface goes away so you don't see this, but + // for completeness sake we should clean this up. + } + + 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(ImguiWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-widget", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + }); + + // Signals + + // Virtual methods + ImguiWidget.virtual_methods.setup.implement(class, imguiSetup); + ImguiWidget.virtual_methods.render.implement(class, imguiRender); + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig new file mode 100644 index 00000000..739e7569 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/inspector_window.zig @@ -0,0 +1,217 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); + +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); + +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const DebugWarning = @import("debug_warning.zig").DebugWarning; +const InspectorWidget = @import("inspector_widget.zig").InspectorWidget; +const WeakRef = @import("../weak_ref.zig").WeakRef; + +const log = std.log.scoped(.gtk_ghostty_inspector_window); + +/// Window for displaying the Ghostty inspector. +pub const InspectorWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.ApplicationWindow; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + + pub const debug = struct { + pub const name = "debug"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = build_config.is_debug, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = struct { + pub fn getter(_: *Self) bool { + return build_config.is_debug; + } + }.getter, + }), + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to + surface: WeakRef(Surface) = .empty, + + /// The embedded inspector widget. + inspector_widget: *InspectorWidget, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Add our dev CSS class if we're in debug mode. + if (comptime build_config.is_debug) { + self.as(gtk.Widget).addCssClass("devel"); + } + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + self.as(gtk.Window).setIconName(build_config.bundle_id); + } + + fn dispose(self: *Self) callconv(.c) void { + // You MUST clear all weak refs in dispose, otherwise it causes + // memory corruption on dispose on the TARGET (weak referenced) + // object. The only way we caught this is via Valgrind. Its not a leak, + // its an invalid memory read. In practice, I found this sometimes + // caused hanging! + self.setSurface(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new(surface: *Surface) *Self { + return gobject.ext.newInstance(Self, .{ + .surface = surface, + }); + } + + /// Present the window. + pub fn present(self: *Self) void { + self.as(gtk.Window).present(); + } + + /// Queue a render of the embedded widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.inspector_widget.queueRender(); + } + + //--------------------------------------------------------------- + // Properties + + fn setSurface(self: *Self, newvalue: ?*Surface) void { + const priv = self.private(); + priv.surface.set(newvalue); + } + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + // Important: get() refs, so we take to not increase ref twice + gobject.ext.Value.take( + value, + self.private().surface.get(), + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propInspectorSurface( + inspector: *InspectorWidget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // If the inspector's surface went away, we destroy the window. + // The inspector has a weak notify on the surface so it knows + // if it goes nil. + if (inspector.getSurface() == null) { + self.as(gtk.Window).destroy(); + } + } + + 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(DebugWarning); + gobject.ext.ensureType(InspectorWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-window", + }), + ); + + // Template Bindings + class.bindTemplateChildPrivate("inspector_widget", .{}); + + // Template callbacks + class.bindTemplateCallback("notify_inspector_surface", &propInspectorSurface); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + properties.debug.impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig new file mode 100644 index 00000000..7aca8f01 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/key_state_overlay.zig @@ -0,0 +1,342 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Application = @import("application.zig").Application; +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_key_state_overlay); + +/// An overlay that displays the current key table stack and pending key sequence. +/// This helps users understand what key bindings are active and what keys they've +/// pressed in a multi-key sequence. +pub const KeyStateOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyKeyStateOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const tables = struct { + pub const name = "tables"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getTables, + .getter_transfer = .none, + .setter = setTables, + .setter_transfer = .full, + }, + ), + }, + ); + }; + + pub const @"has-tables" = struct { + pub const name = "has-tables"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasTables }, + ), + }, + ); + }; + + pub const sequence = struct { + pub const name = "sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getSequence, + .getter_transfer = .none, + .setter = setSequence, + .setter_transfer = .full, + }, + ), + }, + ); + }; + + pub const @"has-sequence" = struct { + pub const name = "has-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSequence }, + ), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + const Private = struct { + /// The key table stack. + tables: ?*ext.StringList = null, + + /// The key sequence. + sequence: ?*ext.StringList = null, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .end, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn getTables(self: *Self) ?*ext.StringList { + return self.private().tables; + } + + fn getSequence(self: *Self) ?*ext.StringList { + return self.private().sequence; + } + + fn setTables(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.tables) |old| { + old.destroy(); + priv.tables = null; + } + if (value) |v| { + priv.tables = v; + } + + self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec); + } + + fn setSequence(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.sequence) |old| { + old.destroy(); + priv.sequence = null; + } + if (value) |v| { + priv.sequence = v; + } + + self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec); + } + + fn getHasTables(self: *Self) bool { + const v = self.private().tables orelse return false; + return v.strings.len > 0; + } + + fn getHasSequence(self: *Self) bool { + const v = self.private().sequence orelse return false; + return v.strings.len > 0; + } + + fn closureShowChevron( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables and has_sequence) 1 else 0; + } + + fn closureHasState( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables or has_sequence) 1 else 0; + } + + fn closureTablesText( + _: *Self, + tables: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = tables orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" > ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + + fn closureSequenceText( + _: *Self, + sequence: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = sequence orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + + //--------------------------------------------------------------- + // Template callbacks + + fn onDragEnd( + _: *gtk.GestureDrag, + _: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // Key state overlay only moves between top-center and bottom-center. + // Horizontal alignment is always center. + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + const new_y = self_y + offset_y + (self_height / 2); + + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + self.as(gtk.Widget).queueResize(); + } + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + 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 priv = self.private(); + + if (priv.tables) |v| { + v.destroy(); + } + if (priv.sequence) |v| { + v.destroy(); + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "key-state-overlay", + }), + ); + + // Template Callbacks + class.bindTemplateCallback("on_drag_end", &onDragEnd); + class.bindTemplateCallback("show_chevron", &closureShowChevron); + class.bindTemplateCallback("has_state", &closureHasState); + class.bindTemplateCallback("tables_text", &closureTablesText); + class.bindTemplateCallback("sequence_text", &closureSequenceText); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.tables.impl, + properties.@"has-tables".impl, + properties.sequence.impl, + properties.@"has-sequence".impl, + properties.@"valign-target".impl, + }); + + // 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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig new file mode 100644 index 00000000..e14f1563 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/resize_overlay.zig @@ -0,0 +1,338 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_resize_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const ResizeOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyResizeOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const duration = struct { + pub const name = "duration"; + const impl = gobject.ext.defineProperty( + name, + Self, + c_uint, + .{ + .default = 750, + .minimum = 250, + .maximum = std.math.maxInt(c_uint), + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "duration", + ), + }, + ); + }; + + pub const @"first-delay" = struct { + pub const name = "first-delay"; + const impl = gobject.ext.defineProperty( + name, + Self, + c_uint, + .{ + .default = 250, + .minimum = 250, + .maximum = std.math.maxInt(c_uint), + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "first_delay", + ), + }, + ); + }; + + pub const label = struct { + pub const name = "label"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("label_text"), + }, + ); + }; + + pub const @"overlay-halign" = struct { + pub const name = "overlay-halign"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .center, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "halign", + ), + }, + ); + }; + + pub const @"overlay-valign" = struct { + pub const name = "overlay-valign"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .center, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "valign", + ), + }, + ); + }; + }; + + const Private = struct { + /// The label with the text + label: *gtk.Label, + + /// The text to set on the label when scheduled. + label_text: ?[:0]const u8, + + /// The time that the overlay appears. + duration: c_uint, + + /// The first delay before any overlay is shown. Must be specified + /// during construction otherwise it has no effect. + first_delay: c_uint, + + /// The idle source that we use to update the label. + idler: ?c_uint = null, + + /// The timer for dismissing the overlay. + timer: ?c_uint = null, + + /// The first delay timer. + delay_timer: ?c_uint = null, + + /// The alignment of the label + halign: gtk.Align, + valign: gtk.Align, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + if (priv.first_delay > 0) { + priv.delay_timer = glib.timeoutAdd( + priv.first_delay, + onDelayTimer, + self, + ); + } + } + + /// Set the label for the overlay. This will not show the + /// overlay if it is currently hidden; you must call schedule. + pub fn setLabel(self: *Self, label: ?[:0]const u8) void { + const priv = self.private(); + if (priv.label_text) |v| glib.free(@ptrCast(@constCast(v))); + priv.label_text = null; + if (label) |v| priv.label_text = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.label.impl.param_spec); + } + + /// Schedule the overlay to be shown. To avoid flickering during + /// resizes we schedule the overlay to be shown on the next idle tick. + pub fn schedule(self: *Self) void { + const priv = self.private(); + + // If we have a delay timer then we're not showing anything + // yet so do nothing. + if (priv.delay_timer != null) return; + + // When updating a widget, wait until GTK is "idle", i.e. not in the middle + // of doing any other updates. Since we are called in the middle of resizing + // GTK is doing a lot of work rearranging all of the widgets. Not doing this + // results in a lot of warnings from GTK and _horrible_ flickering of the + // resize overlay. + if (priv.idler != null) return; + priv.idler = glib.idleAdd(onIdle, self); + } + + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // No matter what our idler is complete with this callback + priv.idler = null; + + // Cancel our previous show timer no matter what. + if (priv.timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove size overlay timer", .{}); + } + priv.timer = null; + } + + // If we have a label to show, show ourselves. If we don't have + // label text, then hide our label. + const text = priv.label_text orelse { + self.as(gtk.Widget).setVisible(0); + return 0; + }; + + // Set our label and show it. + priv.label.setLabel(text); + self.as(gtk.Widget).setVisible(1); + + // Setup the new timer to hide ourselves after the delay. + priv.timer = glib.timeoutAdd( + priv.duration, + onTimer, + self, + ); + + return 0; + } + + fn onTimer(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.timer = null; + self.as(gtk.Widget).setVisible(0); + return 0; + } + + fn onDelayTimer(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.delay_timer = null; + return 0; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.idler) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove resize overlay idler", .{}); + } + priv.idler = null; + } + if (priv.timer) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove resize overlay timer", .{}); + } + priv.timer = null; + } + if (priv.delay_timer) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove resize overlay delay timer", .{}); + } + priv.delay_timer = 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 priv = self.private(); + if (priv.label_text) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.label_text = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "resize-overlay", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("label", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.duration.impl, + properties.label.impl, + properties.@"first-delay".impl, + properties.@"overlay-halign".impl, + properties.@"overlay-valign".impl, + }); + + // 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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig b/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig new file mode 100644 index 00000000..a8111546 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/search_overlay.zig @@ -0,0 +1,493 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_search_overlay); + +/// The overlay that shows the current size while a surface is resizing. +/// This can be used generically to show pretty much anything with a +/// disappearing overlay, but we have no other use at this point so it +/// is named specifically for what it does. +/// +/// General usage: +/// +/// 1. Add it to an overlay +/// 2. Set the label with `setLabel` +/// 3. Schedule to show it with `schedule` +/// +/// Set any properties to change the behavior. +pub const SearchOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySearchOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const active = struct { + pub const name = "active"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getSearchActive, + .setter = setSearchActive, + }, + ), + }, + ); + }; + + pub const @"search-total" = struct { + pub const name = "search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchTotal }, + ), + }, + ); + }; + + pub const @"has-search-total" = struct { + pub const name = "has-search-total"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchTotal }, + ), + }, + ); + }; + + pub const @"search-selected" = struct { + pub const name = "search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + u64, + .{ + .default = 0, + .minimum = 0, + .maximum = std.math.maxInt(u64), + .accessor = gobject.ext.typedAccessor( + Self, + u64, + .{ .getter = getSearchSelected }, + ), + }, + ); + }; + + pub const @"has-search-selected" = struct { + pub const name = "has-search-selected"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSearchSelected }, + ), + }, + ); + }; + + pub const @"halign-target" = struct { + pub const name = "halign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("halign_target"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .start, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when the search is stopped (e.g., Escape pressed). + pub const @"stop-search" = struct { + pub const name = "stop-search"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when the search text changes (debounced). + pub const @"search-changed" = struct { + pub const name = "search-changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?[*:0]const u8}, + void, + ); + }; + + /// Emitted when navigating to the next match. + pub const @"next-match" = struct { + pub const name = "next-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when navigating to the previous match. + pub const @"previous-match" = struct { + pub const name = "previous-match"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The search entry widget. + search_entry: *gtk.SearchEntry, + + /// True when a search is active, meaning we should show the overlay. + active: bool = false, + + /// Total number of search matches (null means unknown/none). + search_total: ?usize = null, + + /// Currently selected match index (null means none selected). + search_selected: ?usize = null, + + /// Target horizontal alignment for the overlay. + halign_target: gtk.Align = .end, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .start, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + /// Grab focus on the search entry and select all text. + pub fn grabFocus(self: *Self) void { + const priv = self.private(); + _ = priv.search_entry.as(gtk.Widget).grabFocus(); + + // Select all text in the search entry field. -1 is distance from + // the end, causing the entire text to be selected. + priv.search_entry.as(gtk.Editable).selectRegion(0, -1); + } + + // Set active status, and update search on activation + fn setSearchActive(self: *Self, active: bool) void { + const priv = self.private(); + if (!priv.active and active) { + const text = priv.search_entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + priv.active = active; + } + + // Set contents of search + pub fn setSearchContents(self: *Self, content: [:0]const u8) void { + const priv = self.private(); + priv.search_entry.as(gtk.Editable).setText(content); + signals.@"search-changed".impl.emit(self, null, .{content}, null); + } + + /// Set the total number of search matches. + pub fn setSearchTotal(self: *Self, total: ?usize) void { + const priv = self.private(); + const had_total = priv.search_total != null; + if (priv.search_total == total) return; + priv.search_total = total; + self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec); + if (had_total != (total != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec); + } + } + + /// Set the currently selected match index. + pub fn setSearchSelected(self: *Self, selected: ?usize) void { + const priv = self.private(); + const had_selected = priv.search_selected != null; + if (priv.search_selected == selected) return; + priv.search_selected = selected; + self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec); + if (had_selected != (selected != null)) { + self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec); + } + } + + fn getSearchActive(self: *Self) bool { + return self.private().active; + } + + fn getSearchTotal(self: *Self) u64 { + return self.private().search_total orelse 0; + } + + fn getHasSearchTotal(self: *Self) bool { + return self.private().search_total != null; + } + + fn getSearchSelected(self: *Self) u64 { + return self.private().search_selected orelse 0; + } + + fn getHasSearchSelected(self: *Self) bool { + return self.private().search_selected != null; + } + + fn closureMatchLabel( + _: *Self, + has_selected: bool, + selected: u64, + has_total: bool, + total: u64, + ) callconv(.c) ?[*:0]const u8 { + if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0"); + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{ + if (has_selected) selected + 1 else 0, + total, + }) catch return null; + return glib.ext.dupeZ(u8, label); + } + + //--------------------------------------------------------------- + // Template callbacks + + fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void { + const text = entry.as(gtk.Editable).getText(); + signals.@"search-changed".impl.emit(self, null, .{text}, null); + } + + // NOTE: The callbacks below use anyopaque for the first parameter + // because they're shared with multiple widgets in the template. + + fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"stop-search".impl.emit(self, null, .{}, null); + } + + fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } + + fn searchEntryKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) { + if (gtk_mods.shift_mask) { + signals.@"previous-match".impl.emit(self, null, .{}, null); + } else { + signals.@"next-match".impl.emit(self, null, .{}, null); + } + + return 1; + } + + return 0; + } + + fn onDragEnd( + _: *gtk.GestureDrag, + offset_x: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // On drag end, we want to move our halign/valign if we crossed + // the midpoint on either axis. This lets the search overlay be + // moved to different corners of the parent container. + + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth()); + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_width: f64 = @floatFromInt(widget.getAllocatedWidth()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width; + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + + const new_x = self_x + offset_x + (self_width / 2); + const new_y = self_y + offset_y + (self_height / 2); + + const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start; + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + var changed = false; + if (new_halign != priv.halign_target) { + priv.halign_target = new_halign; + self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec); + changed = true; + } + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + changed = true; + } + + if (changed) self.as(gtk.Widget).queueResize(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + _ = priv; + + 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 priv = self.private(); + _ = priv; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "search-overlay", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("search_entry", .{}); + + // Template Callbacks + class.bindTemplateCallback("stop_search", &stopSearch); + class.bindTemplateCallback("search_changed", &searchChanged); + class.bindTemplateCallback("match_label_closure", &closureMatchLabel); + class.bindTemplateCallback("next_match", &nextMatch); + class.bindTemplateCallback("previous_match", &previousMatch); + class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed); + class.bindTemplateCallback("on_drag_end", &onDragEnd); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + properties.@"search-total".impl, + properties.@"has-search-total".impl, + properties.@"search-selected".impl, + properties.@"has-search-selected".impl, + properties.@"halign-target".impl, + properties.@"valign-target".impl, + }); + + // Signals + signals.@"stop-search".impl.register(.{}); + signals.@"search-changed".impl.register(.{}); + signals.@"next-match".impl.register(.{}); + signals.@"previous-match".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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig new file mode 100644 index 00000000..311fbd8a --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig @@ -0,0 +1,1290 @@ +const std = @import("std"); +const assert = @import("../../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../../config.zig"); +const apprt = @import("../../../apprt.zig"); +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; +const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Box; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTree", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + /// The active surface is the surface that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = getActiveSurface, + }, + ), + }, + ); + }; + + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getHasSurfaces, + }, + ), + }, + ); + }; + + pub const @"is-zoomed" = struct { + pub const name = "is-zoomed"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsZoomed, + }, + ), + }, + ); + }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, + }, + ); + }; + + pub const @"is-split" = struct { + pub const name = "is-split"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsSplit, + }, + ), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted whenever the tree property has changed, with access + /// to the previous and new values. + pub const changed = struct { + pub const name = "changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{ ?*const Surface.Tree, ?*const Surface.Tree }, + void, + ); + }; + }; + + const Private = struct { + /// The tree datastructure containing all of our surface views. + tree: ?*Surface.Tree, + + // Template bindings + tree_bin: *adw.Bin, + + /// Last focused surface in the tree. We need this to handle various + /// tree change states. + last_focused: WeakRef(Surface) = .empty, + + /// The source that we use to rebuild the tree. This is also + /// used to debounce updates. + rebuild_source: ?c_uint = null, + + /// Used to store state about a pending surface close for the + /// close dialog. + pending_close: ?Surface.Tree.Node.Handle, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Initialize our actions + self.initActionMap(); + + // Initialize some basic state + const priv = self.private(); + priv.pending_close = null; + } + + fn initActionMap(self: *Self) void { + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + const actions = [_]ext.actions.Action(Self){ + // All of these will eventually take a target surface parameter. + // For now all our targets originate from the focused surface. + .init("new-split", actionNewSplit, s_variant_type), + .init("equalize", actionEqualize, null), + .init("zoom", actionZoom, null), + }; + + _ = ext.actions.addAsGroup(Self, self, "split-tree", &actions); + } + + /// Create a new split in the given direction from the currently + /// active surface. + /// + /// If the tree is empty this will create a new tree with a new surface + /// and ignore the direction. + /// + /// The parent will be used as the parent of the surface regardless of + /// if that parent is in this split tree or not. This allows inheriting + /// surface properties from anywhere. + pub fn newSplit( + self: *Self, + direction: Surface.Tree.Split.Direction, + parent_: ?*Surface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + + // Create our new surface. + const surface: *Surface = .new(.{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); + defer surface.unref(); + _ = surface.refSink(); + + // Inherit properly if we were asked to. + if (parent_) |p| { + if (p.core()) |core| { + surface.setParent(core, .split); + } + } + + // Bind is-split property for new surface + _ = self.as(gobject.Object).bindProperty( + "is-split", + surface.as(gobject.Object), + "is-split", + .{ .sync_create = true }, + ); + + // Create our tree + var single_tree = try Surface.Tree.init(alloc, surface); + defer single_tree.deinit(); + + // We want to move our focus to the new surface no matter what. + // But we need to be careful to restore state if we fail. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + // If we have no tree yet, then this becomes our tree and we're done. + const old_tree = self.getTree() orelse { + self.setTree(&single_tree); + return; + }; + + // The handle we create the split relative to. Today this is the active + // surface but this might be the handle of the given parent if we want. + const handle = self.getActiveSurfaceHandle() orelse .root; + + // Create our split! + var new_tree = try old_tree.split( + alloc, + handle, + direction, + 0.5, // Always split equally for new splits + &single_tree, + ); + defer new_tree.deinit(); + log.debug( + "new split at={} direction={} old_tree={f} new_tree={f}", + .{ handle, direction, old_tree, &new_tree }, + ); + + // Replace our tree + self.setTree(&new_tree); + } + + pub fn resize( + self: *Self, + direction: Surface.Tree.Split.Direction, + amount: u16, + ) Allocator.Error!bool { + // Avoid useless work + if (amount == 0) return false; + + const old_tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + + // Get all our dimensions we're going to need to turn our + // amount into a percentage. + const priv = self.private(); + const width = priv.tree_bin.as(gtk.Widget).getWidth(); + const height = priv.tree_bin.as(gtk.Widget).getHeight(); + if (width == 0 or height == 0) return false; + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + const amount_f64: f64 = @floatFromInt(amount); + + // Get our ratio and use positive/neg for directions. + const ratio: f64 = switch (direction) { + .right => amount_f64 / width_f64, + .left => -(amount_f64 / width_f64), + .down => amount_f64 / height_f64, + .up => -(amount_f64 / height_f64), + }; + + const layout: Surface.Tree.Split.Layout = switch (direction) { + .left, .right => .horizontal, + .up, .down => .vertical, + }; + + var new_tree = try old_tree.resize( + Application.default().allocator(), + active, + layout, + @floatCast(ratio), + ); + defer new_tree.deinit(); + self.setTree(&new_tree); + return true; + } + + /// Move focus from the currently focused surface to the given + /// direction. Returns true if focus switched to a new surface. + pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { + const tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + const target = if (tree.goto( + Application.default().allocator(), + active, + to, + )) |handle_| + handle_ orelse return false + else |err| switch (err) { + // Nothing we can do in this scenario. This is highly unlikely + // since split trees don't use that much memory. The application + // is probably about to crash in other ways. + error.OutOfMemory => return false, + }; + + // If we aren't changing targets then we did nothing. + if (active == target) return false; + + // Get the surface at the target location and grab focus. + const surface = tree.nodes[target.idx()].leaf; + surface.grabFocus(); + + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + if (tree.zoomed != null) { + const app = Application.default(); + const config_obj = app.getConfig(); + defer config_obj.unref(); + const config = config_obj.get(); + + if (!config.@"split-preserve-zoom".navigation) { + tree.zoomed = null; + } else { + tree.zoom(target); + } + + // When the zoom state changes our tree state changes and + // we need to send the proper notifications to trigger + // relayout. + const object = self.as(gobject.Object); + object.notifyByPspec(properties.tree.impl.param_spec); + object.notifyByPspec(properties.@"is-zoomed".impl.param_spec); + } + + return true; + } + + fn disconnectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + + fn connectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + } + } + + //--------------------------------------------------------------- + // Properties + + /// Returns true if this split tree needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const tree = self.getTree() orelse return false; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.core()) |core| { + if (core.needsConfirmQuit()) { + return true; + } + } + } + + return false; + } + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getTree() orelse return null; + const handle = self.getActiveSurfaceHandle() orelse return null; + return tree.nodes[handle.idx()].leaf; + } + + fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { + const tree = self.getTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.handle; + } + + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + if (self.private().last_focused.get()) |v| { + defer v.unref(); + + // We need to find the handle of the last focused surface. + it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == v) return entry.handle; + } + } + + return null; + } + + /// Returns the last focused surface in the tree. + pub fn getLastFocusedSurface(self: *Self) ?*Surface { + const surface = self.private().last_focused.get() orelse return null; + // We unref because get() refs the surface. We don't use the weakref + // in a multi-threaded context so this is safe. + surface.unref(); + return surface; + } + + pub fn getHasSurfaces(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return !tree.isEmpty(); + } + + pub fn getIsZoomed(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.zoomed != null; + } + + /// Get the tree data model that we're showing in this widget. This + /// does not clone the tree. + pub fn getTree(self: *Self) ?*Surface.Tree { + return self.private().tree; + } + + /// Set the tree data model that we're showing in this widget. This + /// will clone the given tree. + pub fn setTree(self: *Self, tree_: ?*const Surface.Tree) void { + const priv = self.private(); + + // We always normalize our tree parameter so that empty trees + // become null so that we don't have to deal with callers being + // confused about that. + const tree: ?*const Surface.Tree = tree: { + const tree = tree_ orelse break :tree null; + if (tree.isEmpty()) break :tree null; + break :tree tree; + }; + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.changed.impl.emit( + self, + null, + .{ priv.tree, tree }, + null, + ); + + if (priv.tree) |old_tree| { + self.disconnectSurfaceHandlers(); + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + assert(priv.tree == null); + assert(!new_tree.isEmpty()); + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + self.connectSurfaceHandlers(); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn getTreeValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().tree, + ); + } + + fn setTreeValue(self: *Self, value: *const gobject.Value) void { + self.setTree(gobject.ext.Value.get( + value, + ?*Surface.Tree, + )); + } + + pub fn getIsSplit(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + if (tree.isEmpty()) return false; + + const root_handle: Surface.Tree.Node.Handle = .root; + const root = tree.nodes[root_handle.idx()]; + return switch (root) { + .leaf => false, + .split => true, + }; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + priv.last_focused.set(null); + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = 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 priv = self.private(); + if (priv.tree) |tree| { + ext.boxedFree(Surface.Tree, tree); + priv.tree = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + pub fn actionNewSplit( + _: *gio.SimpleAction, + args_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const args = args_ orelse { + log.warn("split-tree.new-split called without a parameter", .{}); + return; + }; + + var dir: ?[*:0]const u8 = null; + args.get("&s", &dir); + + const direction = std.meta.stringToEnum( + Surface.Tree.Split.Direction, + std.mem.span(dir) orelse return, + ) orelse { + // Need to be defensive here since actions can be triggered externally. + log.warn("invalid split direction for split-tree.new-split: {s}", .{dir.?}); + return; + }; + + self.newSplit( + direction, + self.getActiveSurface(), + .none, + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionEqualize( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + + const old_tree = self.getTree() orelse return; + var new_tree = old_tree.equalize(Application.default().allocator()) catch |err| { + log.warn("unable to equalize tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + } + + pub fn actionZoom( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tree = self.getTree() orelse return; + if (tree.zoomed != null) { + tree.zoomed = null; + } else { + const active = self.getActiveSurfaceHandle() orelse return; + if (tree.zoomed == active) return; + tree.zoom(active); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn surfaceCloseRequest( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + const core = surface.core() orelse return; + + // Reset our pending close state + const priv = self.private(); + priv.pending_close = null; + + // Find the surface in the tree to verify this is valid and + // set our pending close handle. + priv.pending_close = handle: { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == surface) { + break :handle entry.handle; + } + } + + return; + }; + + // If we don't need to confirm then just close immediately. + if (!core.needsConfirmQuit()) { + closeConfirmationClose( + null, + self, + ); + return; + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.surface); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Self, + closeConfirmationClose, + self, + .{}, + ); + dialog.present(self.as(gtk.Widget)); + } + + fn closeConfirmationClose( + _: ?*CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + // Get the handle we're closing + const priv = self.private(); + const handle = priv.pending_close orelse return; + priv.pending_close = null; + + // Figure out our next focus target. The next focus target is + // always the "previous" surface unless we're the leftmost then + // its the next. + const old_tree = self.getTree() orelse return; + const next_focus: ?*Surface = next_focus: { + const alloc = Application.default().allocator(); + const next_handle: Surface.Tree.Node.Handle = + (old_tree.goto(alloc, handle, .previous) catch null) orelse + (old_tree.goto(alloc, handle, .next) catch null) orelse + break :next_focus null; + if (next_handle == handle) break :next_focus null; + + // Note: we don't need to ref this or anything because its + // guaranteed to remain in the new tree since its not part + // of the handle we're removing. + break :next_focus old_tree.nodes[next_handle.idx()].leaf; + }; + + // Remove it from the tree. + var new_tree = old_tree.remove( + Application.default().allocator(), + handle, + ) catch |err| { + log.warn("unable to remove surface from tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + + // Grab focus. We have to set this on the "last focused" because our + // focus will be set when the tree is redrawn. + if (next_focus) |v| priv.last_focused.set(v); + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We never CLEAR our last_focused because the property is specifically + // the last focused surface. We let the weakref clear itself when + // the surface is destroyed. + if (!surface.getFocused()) return; + self.private().last_focused.set(surface); + + // Our active surface probably changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // No matter what we notify + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); + + // If we were planning a rebuild, always remove that so we can + // start from a clean slate. + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + + // If we transitioned to an empty tree, clear immediately instead of + // waiting for an idle callback. Delaying teardown can keep the last + // surface alive during shutdown if the main loop exits first. + if (priv.tree == null) { + priv.tree_bin.setChild(null); + return; + } + + // Build on an idle callback so rapid tree changes are debounced. + // We keep the existing tree attached until the rebuild runs, + // which avoids transient empty frames. + assert(priv.rebuild_source == null); + priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); + } + + fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our rebuild source as null since we're done. + const priv = self.private(); + priv.rebuild_source = null; + + // Rebuild our tree + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + if (tree.isEmpty()) { + priv.tree_bin.setChild(null); + } else { + const built = self.buildTree( + tree, + tree.zoomed orelse .root, + ); + defer built.deinit(); + priv.tree_bin.setChild(built.widget); + } + + // Replacing our tree widget hierarchy can reset focus state. + // If we have a last-focused surface, restore focus to it. + if (priv.last_focused.get()) |v| { + defer v.unref(); + v.grabFocus(); + } + + // Our split status may have changed + self.as(gobject.Object).notifyByPspec(properties.@"is-split".impl.param_spec); + + // Our active surface may have changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + + return 0; + } + + /// Builds the widget tree associated with a surface split tree. + /// + /// Returned widgets are expected to be attached to a parent by the caller. + /// + /// If `release_ref` is true then `widget` has an extra temporary + /// reference that must be released once it is parented in the rebuilt + /// tree. + const BuildTreeResult = struct { + widget: *gtk.Widget, + release_ref: bool, + + pub fn initNew(widget: *gtk.Widget) BuildTreeResult { + return .{ .widget = widget, .release_ref = false }; + } + + pub fn initReused(widget: *gtk.Widget) BuildTreeResult { + // We add a temporary ref to the widget to ensure it doesn't + // get destroyed while we're rebuilding the tree and detaching + // it from its old parent. The caller is expected to release + // this ref once the widget is attached to its new parent. + _ = widget.as(gobject.Object).ref(); + + // Detach after we ref it so that this doesn't mark the + // widget for destruction. + detachWidget(widget); + + return .{ .widget = widget, .release_ref = true }; + } + + pub fn deinit(self: BuildTreeResult) void { + // If we have to release a ref, do it. + if (self.release_ref) self.widget.as(gobject.Object).unref(); + } + }; + + fn buildTree( + self: *Self, + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) BuildTreeResult { + return switch (tree.nodes[current.idx()]) { + .leaf => |v| leaf: { + const window = ext.getAncestor( + SurfaceScrolledWindow, + v.as(gtk.Widget), + ) orelse { + // The surface isn't in a window already so we don't + // have to worry about reuse. + break :leaf .initNew(gobject.ext.newInstance( + SurfaceScrolledWindow, + .{ .surface = v }, + ).as(gtk.Widget)); + }; + + // Keep this widget alive while we detach it from the + // old tree and adopt it into the new one. + break :leaf .initReused(window.as(gtk.Widget)); + }, + .split => |s| split: { + const left = self.buildTree(tree, s.left); + defer left.deinit(); + const right = self.buildTree(tree, s.right); + defer right.deinit(); + + break :split .initNew(SplitTreeSplit.new( + current, + &s, + left.widget, + right.widget, + ).as(gtk.Widget)); + }, + }; + } + + /// Detach a split widget from its current parent. + /// + /// We intentionally use parent-specific child APIs when possible + /// (`GtkPaned.setStartChild/setEndChild`, `AdwBin.setChild`) instead of + /// calling `gtk.Widget.unparent` directly. Container implementations track + /// child pointers/properties internally, and those setters are the path + /// that keeps container state and notifications in sync. + fn detachWidget(widget: *gtk.Widget) void { + const parent = widget.getParent() orelse return; + + // Surface will be in a paned when it is split. + if (gobject.ext.cast(gtk.Paned, parent)) |paned| { + if (paned.getStartChild()) |child| { + if (child == widget) { + paned.setStartChild(null); + return; + } + } + + if (paned.getEndChild()) |child| { + if (child == widget) { + paned.setEndChild(null); + return; + } + } + } + + // Surface will be in a bin when it is not split. + if (gobject.ext.cast(adw.Bin, parent)) |bin| { + if (bin.getChild()) |child| { + if (child == widget) { + bin.setChild(null); + return; + } + } + } + + // Fallback for unexpected parents where we don't have a typed + // container API available. + widget.unparent(); + } + + //--------------------------------------------------------------- + // Class + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, + properties.@"has-surfaces".impl, + properties.@"is-zoomed".impl, + properties.tree.impl, + properties.@"is-split".impl, + }); + + // Bindings + class.bindTemplateChildPrivate("tree_bin", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_tree", &propTree); + + // Signals + signals.changed.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; + }; +}; + +/// This is an internal-only widget that represents a split in the +/// split tree. This is a wrapper around gtk.Paned that allows us to handle +/// ratio (0 to 1) based positioning of the split, and also allows us to +/// write back the updated ratio to the split tree when the user manually +/// adjusts the split position. +/// +/// Since this is internal, it expects to be nested within a SplitTree and +/// will use `getAncestor` to find the SplitTree it belongs to. +/// +/// This is an _immutable_ widget. It isn't meant to be updated after +/// creation. As such, there are no properties or APIs to change the split, +/// access the paned, etc. +const SplitTreeSplit = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTreeSplit", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const Private = struct { + /// The handle of the node in the tree that this split represents. + /// Assumed to be correct. + handle: Surface.Tree.Node.Handle, + + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + // Template bindings + paned: *gtk.Paned, + + pub var offset: c_int = 0; + }; + + /// Create a new split. + /// + /// The reason we don't use GObject properties here is because this is + /// an immutable widget and we don't want to deal with the overhead of + /// all the boilerplate for properties, signals, bindings, etc. + pub fn new( + handle: Surface.Tree.Node.Handle, + split: *const Surface.Tree.Split, + start_child: *gtk.Widget, + end_child: *gtk.Widget, + ) *Self { + const self = gobject.ext.newInstance(Self, .{}); + const priv = self.private(); + priv.handle = handle; + + // Setup our paned fields + const paned = priv.paned; + paned.setStartChild(start_child); + paned.setEndChild(end_child); + paned.as(gtk.Orientable).setOrientation(switch (split.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }); + + // Signals and so on are setup in the template. + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn refresh(self: *Self) void { + const priv = self.private(); + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, + self, + ); + } + + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + const paned = priv.paned; + + // Our idle source is always over + priv.idle = null; + + // Get our split. This is the most dangerous part of this entire + // widget. We assume that this widget is always a child of a + // SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; + + // Current, min, and max positions as pixels. + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + const pos_set: bool = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "position-set", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int) != 0; + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const desired_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return 0; + } + + // If we're out of bounds, then we need to either set the position + // to what we expect OR update our expected ratio. + + // If we've never set the position, then we set it to the desired. + if (!pos_set) { + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + return 0; + } + + // If we've set the position, then this is a manual human update + // and we need to write our update back to the tree. + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + + return 0; + } + + //--------------------------------------------------------------- + // Signal handlers + + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMaxPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMinPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.idle) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle = 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 { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree-split", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("paned", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_max_position", &propMaxPosition); + class.bindTemplateCallback("notify_min_position", &propMinPosition); + class.bindTemplateCallback("notify_position", &propPosition); + + // 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; + }; +}; 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)); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig new file mode 100644 index 00000000..d7dd41bc --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig @@ -0,0 +1,264 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const adw_version = @import("../adw_version.zig"); +const apprt = @import("../../../apprt.zig"); +const gresource = @import("../build/gresource.zig"); +const i18n = @import("../../../os/main.zig").i18n; +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_surface_child_exited); + +pub const SurfaceChildExited = if (adw_version.supportsBanner()) + SurfaceChildExitedBanner +else + SurfaceChildExitedNoop; + +/// Child exited overlay based on adw.Banner introduced in +/// Adwaita 1.3. +const SurfaceChildExitedBanner = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurfaceChildExited", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const data = struct { + pub const name = "data"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*apprt.surface.Message.ChildExited, + .{ + .accessor = C.privateBoxedFieldAccessor("data"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when the banner would like to be closed. + pub const @"close-request" = struct { + pub const name = "close-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The child exited data sent by the apprt. + data: ?*apprt.surface.Message.ChildExited = null, + + // Template bindings + banner: *adw.Banner, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn setData( + self: *Self, + data_: ?*const apprt.surface.Message.ChildExited, + ) void { + const priv = self.private(); + if (priv.data) |v| glib.ext.destroy(v); + const data = data_ orelse { + priv.data = null; + return; + }; + + const ptr = glib.ext.create(apprt.surface.Message.ChildExited); + ptr.* = data.*; + priv.data = ptr; + self.as(gobject.Object).notifyByPspec(properties.data.impl.param_spec); + } + + //--------------------------------------------------------------- + // Signal handlers + + fn propData( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const banner = priv.banner; + const data = priv.data orelse { + // Not localized on purpose. + banner.as(adw.Banner).setTitle("This is a bug in Ghostty. Please report it."); + return; + }; + if (data.exit_code == 0) { + banner.as(adw.Banner).setTitle(i18n._("Command succeeded")); + self.as(gtk.Widget).addCssClass("normal"); + self.as(gtk.Widget).removeCssClass("abnormal"); + } else { + banner.as(adw.Banner).setTitle(i18n._("Command failed")); + self.as(gtk.Widget).removeCssClass("normal"); + self.as(gtk.Widget).addCssClass("abnormal"); + } + } + + fn closeButtonClicked( + _: *adw.Banner, + self: *Self, + ) callconv(.c) void { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + 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 priv = self.private(); + if (priv.data) |v| { + glib.ext.destroy(v); + priv.data = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 3, + .name = "surface-child-exited", + }), + ); + + // Template bindings + class.bindTemplateChildPrivate("banner", .{}); + class.bindTemplateCallback("clicked", &closeButtonClicked); + class.bindTemplateCallback("notify_data", &propData); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.data.impl, + }); + + // Signals + signals.@"close-request".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; + }; +}; + +/// Empty widget that does nothing if we don't have a new enough +/// Adwaita version to support the child exited banner. +const SurfaceChildExitedNoop = extern struct { + /// Can be detected at comptime + pub const noop = true; + + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Widget; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurfaceChildExited", + .classInit = &Class.init, + .parent_class = &Class.parent, + }); + + pub const signals = struct { + pub const @"close-request" = struct { + pub const name = "close-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + pub fn setData( + self: *Self, + _: ?*const apprt.surface.Message.ChildExited, + ) void { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + } + + const C = Common(Self, null); + pub const as = C.as; + pub const ref = C.ref; + 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 { + _ = class; + signals.@"close-request".impl.register(.{}); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig new file mode 100644 index 00000000..dd4f17d1 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig @@ -0,0 +1,228 @@ +const std = @import("std"); +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const gtk_version = @import("../gtk_version.zig"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window); + +/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow. +/// This provides scrollbar functionality for the terminal surface. +/// The surface property can be set during initialization or changed +/// dynamically via the surface property. +pub const SurfaceScrolledWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhostttySurfaceScrolledWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + }; + + const Private = struct { + config: ?*Config = null, + config_binding: ?*gobject.Binding = null, + surface: ?*Surface = null, + scrolled_window: *gtk.ScrolledWindow, + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + if (gtk_version.runtimeUntil(4, 20, 1)) self.disableKineticScroll(); + } + + fn disableKineticScroll(self: *Self) void { + // Until gtk 4.20.1 trackpads have kinetic scrolling behavior regardless + // of `Gtk.ScrolledWindow.kinetic_scrolling`. As a workaround, disable + // EventControllerScroll.kinetic + const controllers = self.private().scrolled_window.as(gtk.Widget).observeControllers(); + defer controllers.unref(); + var i: c_uint = 0; + while (controllers.getObject(i)) |obj| : (i += 1) { + defer obj.unref(); + const controller = gobject.ext.cast(gtk.EventControllerScroll, obj) orelse continue; + var flags = controller.getFlags(); + flags.kinetic = false; + controller.setFlags(flags); + } + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + if (priv.config) |v| { + v.unref(); + priv.config = 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 { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { + const priv = self.private(); + + if (surface_ == priv.surface) return; + + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + priv.surface = surface_; + } + + fn closureScrollbarPolicy( + _: *Self, + config_: ?*Config, + ) callconv(.c) gtk.PolicyType { + const config = if (config_) |c| c.get() else return .automatic; + return switch (config.scrollbar) { + .never => .never, + .system => .automatic, + }; + } + + fn propSurface( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const child: *gtk.Widget = self.as(Parent).getChild().?; + const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?; + scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null); + + // Unbind old config binding if it exists + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + // Bind config from surface to our config property + if (priv.surface) |surface| { + priv.config_binding = surface.as(gobject.Object).bindProperty( + properties.config.name, + self.as(gobject.Object), + properties.config.name, + .{ .sync_create = true }, + ); + } + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "surface-scrolled-window", + }), + ); + + // Bindings + class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy); + class.bindTemplateCallback("notify_surface", &propSurface); + class.bindTemplateChildPrivate("scrolled_window", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.surface.impl, + }); + + // 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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig new file mode 100644 index 00000000..0c60c8cc --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig @@ -0,0 +1,592 @@ +const std = @import("std"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../../config.zig"); +const apprt = @import("../../../apprt.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const SplitTree = @import("split_tree.zig").SplitTree; +const Surface = @import("surface.zig").Surface; +const TitleDialog = @import("title_dialog.zig").TitleDialog; + +const log = std.log.scoped(.gtk_ghostty_window); + +pub const Tab = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Box; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyTab", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + /// The active surface is the surface that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = Self.getActiveSurface, + }, + ), + }, + ); + }; + + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"split-tree" = struct { + pub const name = "split-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*SplitTree, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*SplitTree, + .{ + .getter = getSplitTree, + }, + ), + }, + ); + }; + + pub const @"surface-tree" = struct { + pub const name = "surface-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface.Tree, + .{ + .getter = getSurfaceTree, + }, + ), + }, + ); + }; + + pub const tooltip = struct { + pub const name = "tooltip"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("tooltip"), + }, + ); + }; + + 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 signals = struct { + /// Emitted whenever the tab would like to be closed. + pub const @"close-request" = struct { + pub const name = "close-request"; + 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 title of this tab. This is usually bound to the active surface. + title: ?[:0]const u8 = null, + + /// The manually overridden title from `promptTabTitle`. + title_override: ?[:0]const u8 = null, + + /// The tooltip of this tab. This is usually bound to the active surface. + tooltip: ?[:0]const u8 = null, + + // Template bindings + split_tree: *SplitTree, + + pub var offset: c_int = 0; + }; + + /// Set the parent of this tab page. This only affects the first surface + /// ever created for a tab. If a surface was already created this does + /// nothing. + pub fn setParent(self: *Self, parent: *CoreSurface) void { + self.setParentWithContext(parent, .tab); + } + + pub fn setParentWithContext(self: *Self, parent: *CoreSurface, context: apprt.surface.NewSurfaceContext) void { + if (self.getActiveSurface()) |surface| { + surface.setParent(parent, context); + } + } + + pub fn new(config: ?*Config, overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) *Self { + const tab = gobject.ext.newInstance(Tab, .{}); + + const priv: *Private = tab.private(); + + if (config) |c| priv.config = c.ref(); + + // 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(); + } + + tab.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); + + // Create our initial surface in the split tree. + priv.split_tree.newSplit(.right, null, .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }) catch |err| switch (err) { + error.OutOfMemory => { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + }, + }; + + return tab; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Init our actions + self.initActionMap(); + } + + fn initActionMap(self: *Self) void { + const s_param_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_param_type.free(); + + const actions = [_]ext.actions.Action(Self){ + .init("close", actionClose, s_param_type), + .init("ring-bell", actionRingBell, null), + .init("next-page", actionNextPage, null), + .init("previous-page", actionPreviousPage, null), + .init("prompt-tab-title", actionPromptTabTitle, null), + }; + + _ = ext.actions.addAsGroup(Self, self, "tab", &actions); + } + + //--------------------------------------------------------------- + // Properties + + /// 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); + } + 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); + } + pub fn promptTabTitle(self: *Self) void { + const priv = self.private(); + const dialog = TitleDialog.new(.tab, priv.title_override orelse priv.title); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + return self.getSplitTree().getActiveSurface(); + } + + /// Get the surface tree of this tab. + pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { + const priv = self.private(); + return priv.split_tree.getTree(); + } + + /// Get the split tree widget that is in this tab. + pub fn getSplitTree(self: *Self) *SplitTree { + const priv = self.private(); + return priv.split_tree; + } + + /// Returns true if this tab needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const tree = self.getSplitTree(); + return tree.getNeedsConfirmQuit(); + } + + /// Get the tab view holding this tab, if any. + fn getTabView(self: *Self) ?*adw.TabView { + return ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ); + } + + /// Get the tab page holding this tab, if any. + fn getTabPage(self: *Self) ?*adw.TabPage { + const tab_view = self.getTabView() orelse return null; + return tab_view.getPage(self.as(gtk.Widget)); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = 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 priv = self.private(); + if (priv.tooltip) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.tooltip = 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; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + //--------------------------------------------------------------- + // Signal handlers + + fn propSplitTree( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); + + // If our tree is empty we close the tab. + const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty; + if (tree.isEmpty()) { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + return; + } + } + + fn propActiveSurface( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + fn actionClose( + _: *gio.SimpleAction, + param_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const param = param_ orelse { + log.warn("tab.close-tab called without a parameter", .{}); + return; + }; + + var str: ?[*:0]const u8 = null; + param.get("&s", &str); + + const tab_view = self.getTabView() orelse return; + const page = tab_view.getPage(self.as(gtk.Widget)); + + const mode = std.meta.stringToEnum( + apprt.action.CloseTabMode, + std.mem.span( + str orelse { + log.warn("invalid mode provided to tab.close-tab", .{}); + return; + }, + ), + ) orelse { + // Need to be defensive here since actions can be triggered externally. + log.warn("invalid mode provided to tab.close-tab: {s}", .{str.?}); + return; + }; + + // Delegate to our parent to handle this, since this will emit + // a close-page signal that the parent can intercept. + switch (mode) { + .this => tab_view.closePage(page), + .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), + } + } + + fn actionPromptTabTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + self.promptTabTitle(); + } + + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + // Future note: I actually don't like this logic living here at all. + // I think a better approach will be for the ring bell action to + // specify its sending surface and then do all this in the window. + + // If the page is selected already we don't mark it as needing + // attention. We only want to mark unfocused pages. This will then + // clear when the page is selected. + const page = self.getTabPage() orelse return; + if (page.getSelected() != 0) return; + page.setNeedsAttention(@intFromBool(true)); + } + + /// Select the next tab page. + fn actionNextPage( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tab_view = self.getTabView() orelse return; + _ = tab_view.selectNextPage(); + } + + /// Select the previous tab page. + fn actionPreviousPage( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tab_view = self.getTabView() orelse return; + _ = tab_view.selectPreviousPage(); + } + + fn closureComputedTitle( + _: *Self, + config_: ?*Config, + terminal_: ?[*:0]const u8, + surface_override_: ?[*:0]const u8, + tab_override_: ?[*:0]const u8, + zoomed_: c_int, + bell_ringing_: c_int, + _: *gobject.ParamSpec, + ) callconv(.c) ?[*:0]const u8 { + const zoomed = zoomed_ != 0; + const bell_ringing = bell_ringing_ != 0; + + // Our plain title is the manually tab overridden title if it exists, + // otherwise the overridden title if it exists, otherwise + // the terminal title if it exists, otherwise a default string. + const plain = plain: { + const default = "Ghostty"; + const config_title: ?[*:0]const u8 = title: { + const config = config_ orelse break :title null; + break :title config.get().title orelse null; + }; + + const plain = tab_override_ orelse + surface_override_ orelse + terminal_ orelse + config_title orelse + break :plain default; + break :plain std.mem.span(plain); + }; + + // We don't need a config in every case, but if we don't have a config + // let's just assume something went terribly wrong and use our + // default title. Its easier then guarding on the config existing + // in every case for something so unlikely. + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computed title, likely bug", .{}); + return glib.ext.dupeZ(u8, plain); + }; + + // Use an allocator to build up our string as we write it. + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + // If our bell is ringing, then we prefix the bell icon to the title. + if (bell_ringing and config.@"bell-features".title) { + buf.writer.writeAll("🔔 ") catch {}; + } + + // If we're zoomed, prefix with the magnifying glass emoji. + if (zoomed) { + buf.writer.writeAll("🔍 ") catch {}; + } + + buf.writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain); + return glib.ext.dupeZ(u8, buf.written()); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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(SplitTree); + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "tab", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, + properties.config.impl, + properties.@"split-tree".impl, + properties.@"surface-tree".impl, + properties.title.impl, + properties.@"title-override".impl, + properties.tooltip.impl, + }); + + // Bindings + class.bindTemplateChildPrivate("split_tree", .{}); + + // Template Callbacks + class.bindTemplateCallback("computed_title", &closureComputedTitle); + class.bindTemplateCallback("notify_active_surface", &propActiveSurface); + class.bindTemplateCallback("notify_tree", &propSplitTree); + + // Signals + signals.@"close-request".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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig new file mode 100644 index 00000000..ac95ae4b --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig @@ -0,0 +1,234 @@ +const std = @import("std"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const i18n = @import("../../../os/main.zig").i18n; +const ext = @import("../ext.zig"); +const Common = @import("../class.zig").Common; +const Dialog = @import("dialog.zig").Dialog; + +const log = std.log.scoped(.gtk_ghostty_title_dialog); + +pub const TitleDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.AlertDialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyTitleDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const target = struct { + pub const name = "target"; + const impl = gobject.ext.defineProperty( + name, + Self, + Target, + .{ + .default = .surface, + .accessor = gobject.ext + .privateFieldAccessor( + Self, + Private, + &Private.offset, + "target", + ), + }, + ); + }; + pub const @"initial-value" = struct { + pub const name = "initial-value"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("initial_value"), + }, + ); + }; + }; + + pub const signals = struct { + /// Set the title to the given value. + pub const set = struct { + pub const name = "set"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{[*:0]const u8}, + void, + ); + }; + }; + + const Private = struct { + /// The initial value of the entry field. + initial_value: ?[:0]const u8 = null, + + // Template bindings + target: Target, + entry: *gtk.Entry, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn new(target: Target, initial_value: ?[:0]const u8) *Self { + return gobject.ext.newInstance(Self, .{ .target = target, .@"initial-value" = initial_value }); + } + + pub fn present(self: *Self, parent_: *gtk.Widget) void { + // If we have a window we can attach to, we prefer that. + const parent: *gtk.Widget = if (ext.getAncestor( + adw.ApplicationWindow, + parent_, + )) |window| + window.as(gtk.Widget) + else if (ext.getAncestor( + adw.Window, + parent_, + )) |window| + window.as(gtk.Widget) + else + parent_; + + // Set our initial value + const priv = self.private(); + if (priv.initial_value) |v| { + priv.entry.getBuffer().setText(v, -1); + } + + // Set the title for the dialog + self.as(Dialog.Parent).setHeading(priv.target.title()); + + // Show it. We could also just use virtual methods to bind to + // response but this is pretty simple. + self.as(adw.AlertDialog).choose( + parent, + null, + alertDialogReady, + self, + ); + } + + fn alertDialogReady( + _: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + const response = self.as(adw.AlertDialog).chooseFinish(result); + + // If we didn't hit "okay" then we do nothing. + if (std.mem.orderZ(u8, "ok", response) != .eq) return; + + // Emit our signal with the new title. + const title = std.mem.span(self.private().entry.getBuffer().getText()); + signals.set.impl.emit( + self, + null, + .{title.ptr}, + null, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + 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 priv = self.private(); + if (priv.initial_value) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.initial_value = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "title-dialog", + }), + ); + + // Signals + signals.set.impl.register(.{}); + + // Bindings + class.bindTemplateChildPrivate("entry", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"initial-value".impl, + properties.target.impl, + }); + + // 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; + }; +}; + +pub const Target = enum(c_int) { + surface, + tab, + pub fn title(self: Target) [*:0]const u8 { + return switch (self) { + .surface => i18n._("Change Terminal Title"), + .tab => i18n._("Change Tab Title"), + }; + } + + pub const getGObjectType = gobject.ext.defineEnum( + Target, + .{ .name = "GhosttyTitleDialogTarget" }, + ); +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig new file mode 100644 index 00000000..bf2a2fe7 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig @@ -0,0 +1,2111 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = @import("../../../quirks.zig").inlineAssert; +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 i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const configpkg = @import("../../../config.zig"); +const TitlebarStyle = configpkg.Config.GtkTitlebarStyle; +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const gresource = @import("../build/gresource.zig"); +const winprotopkg = @import("../winproto.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; +const Surface = @import("surface.zig").Surface; +const Tab = @import("tab.zig").Tab; +const DebugWarning = @import("debug_warning.zig").DebugWarning; +const CommandPalette = @import("command_palette.zig").CommandPalette; +const WeakRef = @import("../weak_ref.zig").WeakRef; + +const log = std.log.scoped(.gtk_ghostty_window); + +pub const Window = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.ApplicationWindow; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + /// The active surface is the focus that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = Self.getActiveSurface, + }, + ), + }, + ); + }; + + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const debug = struct { + pub const name = "debug"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = build_config.is_debug, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = struct { + pub fn getter(_: *Self) bool { + return build_config.is_debug; + } + }.getter, + }), + }, + ); + }; + + pub const @"titlebar-style" = struct { + pub const name = "titlebar-style"; + const impl = gobject.ext.defineProperty( + name, + Self, + TitlebarStyle, + .{ + .default = .native, + .accessor = gobject.ext.typedAccessor( + Self, + TitlebarStyle, + .{ + .getter = Self.getTitlebarStyle, + }, + ), + }, + ); + }; + + pub const @"headerbar-visible" = struct { + pub const name = "headerbar-visible"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getHeaderbarVisible, + }), + }, + ); + }; + + pub const @"quick-terminal" = struct { + pub const name = "quick-terminal"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = true, + .accessor = gobject.ext.privateFieldAccessor( + Self, + Private, + &Private.offset, + "quick_terminal", + ), + }, + ); + }; + + pub const @"tabs-autohide" = struct { + pub const name = "tabs-autohide"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsAutohide, + }), + }, + ); + }; + + pub const @"tabs-wide" = struct { + pub const name = "tabs-wide"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsWide, + }), + }, + ); + }; + + pub const @"tabs-visible" = struct { + pub const name = "tabs-visible"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsVisible, + }), + }, + ); + }; + + pub const @"toolbar-style" = struct { + pub const name = "toolbar-style"; + const impl = gobject.ext.defineProperty( + name, + Self, + adw.ToolbarStyle, + .{ + .default = .raised, + .accessor = gobject.ext.typedAccessor( + Self, + adw.ToolbarStyle, + .{ + .getter = Self.getToolbarStyle, + }, + ), + }, + ); + }; + }; + + const Private = struct { + /// Whether this window is a quick terminal. If it is then it + /// behaves slightly differently under certain scenarios. + quick_terminal: bool = false, + + /// The window decoration override. If this is not set then we'll + /// inherit whatever the config has. This allows overriding the + /// config on a per-window basis. + window_decoration: ?configpkg.WindowDecoration = null, + + /// Binding group for our active tab. + tab_bindings: *gobject.BindingGroup, + + /// The configuration that this surface is using. + config: ?*Config = null, + + /// State and logic for windowing protocol for a window. + winproto: winprotopkg.Window, + + /// Kind of hacky to have this but this lets us know if we've + /// initialized any single surface yet. We need this because we + /// gate default size on this so that we don't resize the window + /// after surfaces already exist. + /// + /// I think long term we can probably get rid of this by implementing + /// a property or method that gets us all the surfaces in all the + /// tabs and checking if we have zero or one that isn't initialized. + /// + /// For now, this logic is more similar to our legacy GTK side. + surface_init: bool = false, + + /// See tabOverviewOpen for why we have this. + tab_overview_focus_timer: ?c_uint = null, + + /// A weak reference to a command palette. + command_palette: WeakRef(CommandPalette) = .empty, + + /// Tab page that the context menu was opened for. + /// setup by `setup-menu`. + context_menu_page: ?*adw.TabPage = null, + + // Template bindings + tab_overview: *adw.TabOverview, + tab_bar: *adw.TabBar, + tab_view: *adw.TabView, + toolbar: *adw.ToolbarView, + toast_overlay: *adw.ToastOverlay, + + pub var offset: c_int = 0; + }; + + pub fn new( + app: *Application, + overrides: struct { + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) *Self { + const win = gobject.ext.newInstance(Self, .{ + .application = app, + }); + + if (overrides.title) |title| { + // If the overrides have a title set, we set that immediately + // so that any applications inspecting the window states see an + // immediate title set when the window appears, rather than waiting + // possibly a few event loop ticks for it to sync from the surface. + win.as(gtk.Window).setTitle(title); + } + + return win; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // If our configuration is null then we get the configuration + // from the application. + const priv = self.private(); + + const config = config: { + if (priv.config) |config| break :config config.get(); + const app = Application.default(); + const config = app.getConfig(); + priv.config = config; + break :config config.get(); + }; + + // We initialize our windowing protocol to none because we can't + // actually initialize this until we get realized. + priv.winproto = .none; + + // Add our dev CSS class if we're in debug mode. + if (comptime build_config.is_debug) { + self.as(gtk.Widget).addCssClass("devel"); + } + + // Setup our tab binding group. This ensures certain properties + // are only synced from the currently active tab. + priv.tab_bindings = gobject.BindingGroup.new(); + priv.tab_bindings.bind("title", self.as(gobject.Object), "title", .{}); + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + self.as(gtk.Window).setIconName(build_config.bundle_id); + + // Initialize our actions + self.initActionMap(); + + // Start states based on config. + if (config.maximize) self.as(gtk.Window).maximize(); + if (config.fullscreen != .false) self.as(gtk.Window).fullscreen(); + + // If we have an explicit title set, we set that immediately + // so that any applications inspecting the window states see + // an immediate title set when the window appears, rather than + // waiting possibly a few event loop ticks for it to sync from + // the surface. + if (config.title) |title| { + self.as(gtk.Window).setTitle(title); + } + + // We always sync our appearance at the end because loading our + // config and such can affect our bindings which are setup initially + // in initTemplate. + self.syncAppearance(); + + // We need to do this so that the title initializes properly, + // I think because its a dynamic getter. + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + /// Setup our action map. + fn initActionMap(self: *Self) void { + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + const actions = [_]ext.actions.Action(Self){ + .init("about", actionAbout, null), + .init("close", actionClose, null), + .init("close-tab", actionCloseTab, s_variant_type), + .init("new-tab", actionNewTab, null), + .init("new-window", actionNewWindow, null), + .init("prompt-surface-title", actionPromptSurfaceTitle, null), + .init("prompt-tab-title", actionPromptTabTitle, null), + .init("prompt-context-tab-title", actionPromptContextTabTitle, null), + .init("ring-bell", actionRingBell, null), + .init("split-right", actionSplitRight, null), + .init("split-left", actionSplitLeft, null), + .init("split-up", actionSplitUp, null), + .init("split-down", actionSplitDown, null), + .init("copy", actionCopy, null), + .init("paste", actionPaste, null), + .init("reset", actionReset, null), + .init("clear", actionClear, null), + // TODO: accept the surface that toggled the command palette + .init("toggle-command-palette", actionToggleCommandPalette, null), + .init("toggle-inspector", actionToggleInspector, null), + }; + + ext.actions.add(Self, self, &actions); + } + + /// Winproto backend for this window. + pub fn winproto(self: *Self) *winprotopkg.Window { + return &self.private().winproto; + } + + /// Create a new tab with the given parent. The tab will be inserted + /// at the position dictated by the `window-new-tab-position` config. + /// The new tab will be selected. + pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { + _ = self.newTabPage(parent_, .tab, .none); + } + + pub fn newTabForWindow( + self: *Self, + parent_: ?*CoreSurface, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) void { + _ = self.newTabPage( + parent_, + .window, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); + } + + fn newTabPage( + self: *Self, + parent_: ?*CoreSurface, + context: apprt.surface.NewSurfaceContext, + overrides: struct { + command: ?configpkg.Command = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) *adw.TabPage { + const priv: *Private = self.private(); + const tab_view = priv.tab_view; + + // Create our new tab object + const tab = Tab.new( + priv.config, + .{ + .command = overrides.command, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ); + + if (parent_) |p| { + // For a new window's first tab, inherit the parent's initial size hints. + if (context == .window) { + surfaceInit(p.rt_surface.gobj(), self); + } + tab.setParentWithContext(p, context); + } + + // Get the position that we should insert the new tab at. + const config = if (priv.config) |v| v.get() else { + // If we don't have a config we just append it at the end. + // This should never happen. + return tab_view.append(tab.as(gtk.Widget)); + }; + const position = switch (config.@"window-new-tab-position") { + .current => current: { + const selected = tab_view.getSelectedPage() orelse + break :current tab_view.getNPages(); + const current = tab_view.getPagePosition(selected); + break :current current + 1; + }, + + .end => tab_view.getNPages(), + }; + + // Add the page and select it + const page = tab_view.insert(tab.as(gtk.Widget), position); + tab_view.setSelectedPage(page); + + // Create some property bindings + _ = tab.as(gobject.Object).bindProperty( + "title", + page.as(gobject.Object), + "title", + .{ .sync_create = true }, + ); + _ = tab.as(gobject.Object).bindProperty( + "tooltip", + page.as(gobject.Object), + "tooltip", + .{ .sync_create = true }, + ); + + // Bind signals + const split_tree = tab.getSplitTree(); + _ = SplitTree.signals.changed.connect( + split_tree, + *Self, + tabSplitTreeChanged, + self, + .{}, + ); + + // Run an initial notification for the surface tree so we can setup + // initial state. + tabSplitTreeChanged( + split_tree, + null, + split_tree.getTree(), + self, + ); + + return page; + } + + pub const SelectTab = union(enum) { + previous, + next, + last, + n: usize, + }; + + /// Select the tab as requested. Returns true if the tab selection + /// changed. + pub fn selectTab(self: *Self, n: SelectTab) bool { + const priv = self.private(); + const tab_view = priv.tab_view; + + // Get our current tab numeric position + const selected = tab_view.getSelectedPage() orelse return false; + const current = tab_view.getPagePosition(selected); + + // Get our total + const total = tab_view.getNPages(); + + const goto: c_int = switch (n) { + .previous => if (current > 0) + current - 1 + else + total - 1, + + .next => if (current < total - 1) + current + 1 + else + 0, + + .last => total - 1, + + .n => |v| n: { + // 1-indexed + if (v == 0) return false; + + const n_int = std.math.cast( + c_int, + v, + ) orelse return false; + break :n @min(n_int - 1, total - 1); + }, + }; + assert(goto >= 0); + assert(goto < total); + + // If our target is the same as our current then we do nothing. + if (goto == current) return false; + + // Add the page and select it + const page = tab_view.getNthPage(goto); + tab_view.setSelectedPage(page); + + return true; + } + + /// Move the tab containing the given surface by the given amount. + /// Returns if this affected any tab positioning. + pub fn moveTab( + self: *Self, + surface: *Surface, + amount: isize, + ) bool { + const priv = self.private(); + const tab_view = priv.tab_view; + + // If we have one tab we never move. + const total = tab_view.getNPages(); + if (total == 1) return false; + + // Get the tab that contains the given surface. + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse return false; + + // Get the page position that contains the tab. + const page = tab_view.getPage(tab.as(gtk.Widget)); + const pos = tab_view.getPagePosition(page); + + // Move it + const desired_pos: c_int = desired: { + const initial: c_int = @intCast(pos + amount); + const max = total - 1; + break :desired if (initial < 0) + max + initial + 1 + else if (initial > max) + initial - max - 1 + else + initial; + }; + assert(desired_pos >= 0); + assert(desired_pos < total); + + return tab_view.reorderPage(page, desired_pos) != 0; + } + + pub fn toggleTabOverview(self: *Self) void { + const priv = self.private(); + const tab_overview = priv.tab_overview; + const is_open = tab_overview.getOpen() != 0; + tab_overview.setOpen(@intFromBool(!is_open)); + } + + /// Toggle the visible property. + pub fn toggleVisibility(self: *Self) void { + const widget = self.as(gtk.Widget); + widget.setVisible(@intFromBool(widget.isVisible() == 0)); + } + + /// Updates various appearance properties. This should always be safe + /// to call multiple times. This should be called whenever a change + /// happens that might affect how the window appears (config change, + /// fullscreen, etc.). + fn syncAppearance(self: *Self) void { + const priv = self.private(); + const widget = self.as(gtk.Widget); + + // Toggle style classes based on whether we're using CSDs or SSDs. + // + // These classes are defined in the gtk.Window documentation: + // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. + { + // Reset all style classes first + inline for (&.{ + "ssd", + "csd", + "solid-csd", + "no-border-radius", + }) |class| + widget.removeCssClass(class); + + const csd_enabled = priv.winproto.clientSideDecorationEnabled(); + self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled)); + + if (csd_enabled) { + const display = widget.getDisplay(); + + // We do the exact same check GTK is doing internally and toggle + // either the `csd` or `solid-csd` style, based on whether the user's + // window manager is deemed _non-compositing_. + // + // In practice this only impacts users of traditional X11 window + // managers (e.g. i3, dwm, awesomewm, etc.) and not X11 desktop + // environments or Wayland compositors/DEs. + if (display.isRgba() != 0 and display.isComposited() != 0) { + widget.addCssClass("csd"); + } else { + widget.addCssClass("solid-csd"); + } + } else { + widget.addCssClass("ssd"); + // Fix any artifacting that may occur in window corners. + widget.addCssClass("no-border-radius"); + } + } + + // Trigger all our dynamic properties that depend on the config. + inline for (&.{ + "headerbar-visible", + "tabs-autohide", + "tabs-visible", + "tabs-wide", + "toolbar-style", + "titlebar-style", + }) |key| { + self.as(gobject.Object).notifyByPspec( + @field(properties, key).impl.param_spec, + ); + } + + // Remainder uses the config + const config = if (priv.config) |v| v.get() else return; + + // Only add a solid background if we're opaque. + self.toggleCssClass( + "background", + config.@"background-opacity" >= 1, + ); + + // Apply class to color headerbar if window-theme is set to `ghostty` and + // GTK version is before 4.16. The conditional is because above 4.16 + // we use GTK CSS color variables. + self.toggleCssClass( + "window-theme-ghostty", + !gtk_version.atLeast(4, 16, 0) and + config.@"window-theme" == .ghostty, + ); + + // Move the tab bar to the proper location. + priv.toolbar.remove(priv.tab_bar.as(gtk.Widget)); + switch (config.@"gtk-tabs-location") { + .top => priv.toolbar.addTopBar(priv.tab_bar.as(gtk.Widget)), + .bottom => priv.toolbar.addBottomBar(priv.tab_bar.as(gtk.Widget)), + } + + // Do our window-protocol specific appearance sync. + priv.winproto.syncAppearance() catch |err| { + log.warn("failed to sync winproto appearance error={}", .{err}); + }; + } + + /// Sync the state of any actions on this window. + fn syncActions(self: *Self) void { + const has_selection = selection: { + const surface = self.getActiveSurface() orelse + break :selection false; + const core_surface = surface.core() orelse + break :selection false; + break :selection core_surface.hasSelection(); + }; + + const action_map: *gio.ActionMap = gobject.ext.cast( + gio.ActionMap, + self, + ) orelse return; + const action: *gio.SimpleAction = gobject.ext.cast( + gio.SimpleAction, + action_map.lookupAction("copy") orelse return, + ) orelse return; + action.setEnabled(@intFromBool(has_selection)); + } + + fn toggleCssClass(self: *Self, class: [:0]const u8, value: bool) void { + const widget = self.as(gtk.Widget); + if (value) + widget.addCssClass(class.ptr) + else + widget.removeCssClass(class.ptr); + } + + /// Perform a binding action on the window's active surface. + fn performBindingAction( + self: *Self, + action: input.Binding.Action, + ) void { + const surface = self.getActiveSurface() orelse return; + const core_surface = surface.core() orelse return; + _ = core_surface.performBindingAction(action) catch |err| { + log.warn("error performing binding action error={}", .{err}); + return; + }; + } + + /// Queue a simple text-based toast. All text-based toasts share the + /// same timeout for consistency. + /// + // This is not `pub` because we should be using signals emitted by + // other widgets to trigger our toasts. Other objects should not + // trigger toasts directly. + fn addToast(self: *Self, title: [*:0]const u8) void { + const toast = adw.Toast.new(title); + toast.setTimeout(3); + self.private().toast_overlay.addToast(toast); + } + + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + const priv = self.private(); + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + // Before adding any new signal handlers, disconnect any that we may + // have added before. Otherwise we may get multiple handlers for the + // same signal. + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + + _ = Surface.signals.@"present-request".connect( + surface, + *Self, + surfacePresentRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple surfaces + // before init so we could register this on multiple and that is not + // a problem because we'll check the flag again in each handler. + if (!priv.surface_init) { + _ = Surface.signals.init.connect( + surface, + *Self, + surfaceInit, + self, + .{}, + ); + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + + //--------------------------------------------------------------- + // Properties + + /// Whether this terminal is a quick terminal or not. + pub fn isQuickTerminal(self: *Self) bool { + return self.private().quick_terminal; + } + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + const tab = self.getSelectedTab() orelse return null; + return tab.getActiveSurface(); + } + + /// Returns the configuration for this window. The reference count + /// is not increased. + pub fn getConfig(self: *Self) ?*Config { + return self.private().config; + } + + /// Get the tab view for this window. + pub fn getTabView(self: *Self) *adw.TabView { + return self.private().tab_view; + } + + /// Get the current window decoration value for this window. + pub fn getWindowDecoration(self: *Self) configpkg.WindowDecoration { + const priv = self.private(); + if (priv.window_decoration) |v| return v; + if (priv.config) |v| return v.get().@"window-decoration"; + return .auto; + } + + /// Toggle the window decorations for this window. + pub fn toggleWindowDecorations(self: *Self) void { + const priv = self.private(); + + if (priv.window_decoration) |_| { + // Unset any previously set window decoration settings + self.setWindowDecoration(null); + return; + } + + const config = if (priv.config) |v| v.get() else return; + self.setWindowDecoration(switch (config.@"window-decoration") { + // Use auto when the decoration is initially none + .none => .auto, + + // Anything non-none to none + .auto, .client, .server => .none, + }); + } + + /// Set the window decoration override for this window. If this is null, + /// then we'll revert back to the configuration's default. + fn setWindowDecoration( + self: *Self, + new_: ?configpkg.WindowDecoration, + ) void { + const priv = self.private(); + priv.window_decoration = new_; + self.syncAppearance(); + } + + /// Get the currently selected tab as a Tab object. + fn getSelectedTab(self: *Self) ?*Tab { + const priv = self.private(); + const page = priv.tab_view.getSelectedPage() orelse return null; + const child = page.getChild(); + assert(gobject.ext.isA(child, Tab)); + return gobject.ext.cast(Tab, child); + } + + /// Returns true if this window needs confirmation before quitting. + fn getNeedsConfirmQuit(self: *Self) bool { + const priv = self.private(); + const n = priv.tab_view.getNPages(); + assert(n >= 0); + + for (0..@intCast(n)) |i| { + const page = priv.tab_view.getNthPage(@intCast(i)); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse { + log.warn("unexpected non-Tab child in tab view", .{}); + continue; + }; + if (tab.getNeedsConfirmQuit()) return true; + } + + return false; + } + + fn isFullscreen(self: *Window) bool { + return self.as(gtk.Window).isFullscreen() != 0; + } + + fn isMaximized(self: *Window) bool { + return self.as(gtk.Window).isMaximized() != 0; + } + + fn getHeaderbarVisible(self: *Self) bool { + const priv = self.private(); + + // Never display the header bar when CSDs are disabled. + const csd_enabled = priv.winproto.clientSideDecorationEnabled(); + if (!csd_enabled) return false; + + // Never display the header bar as a quick terminal. + if (priv.quick_terminal) return false; + + // If we're fullscreen we never show the header bar. + if (self.isFullscreen()) return false; + + // The remainder needs a config + const config_obj = self.private().config orelse return true; + const config = config_obj.get(); + + // *Conditionally* disable the header bar when maximized, and + // gtk-titlebar-hide-when-maximized is set + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") { + return false; + } + + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs never show the titlebar. + .tabs => false, + + // If the titlebar style is native show the titlebar if configured + // to do so. + .native => config.@"gtk-titlebar", + }; + } + + fn getTabsAutohide(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs we cannot autohide. + .tabs => false, + + .native => switch (config.@"window-show-tab-bar") { + // Auto we always autohide... obviously. + .auto => true, + + // Always we never autohide because we always show the tab bar. + .always => false, + + // Never we autohide because it doesn't actually matter, + // since getTabsVisible will return false. + .never => true, + }, + }; + } + + fn getTabsVisible(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + + switch (config.@"gtk-titlebar-style") { + .tabs => { + // *Conditionally* disable the tab bar when maximized, the titlebar + // style is tabs, and gtk-titlebar-hide-when-maximized is set. + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") return false; + + // If the titlebar style is tabs the tab bar must always be visible. + return true; + }, + .native => { + return switch (config.@"window-show-tab-bar") { + .always, .auto => true, + .never => false, + }; + }, + } + } + + fn getTabsWide(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + return config.@"gtk-wide-tabs"; + } + + fn getToolbarStyle(self: *Self) adw.ToolbarStyle { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return .raised; + return switch (config.@"gtk-toolbar-style") { + .flat => .flat, + .raised => .raised, + .@"raised-border" => .raised_border, + }; + } + + fn getTitlebarStyle(self: *Self) TitlebarStyle { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return .native; + return config.@"gtk-titlebar-style"; + } + + fn propConfig( + _: *adw.ApplicationWindow, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + if (priv.config) |config_obj| { + const config = config_obj.get(); + if (config.@"app-notifications".@"config-reload") { + self.addToast(i18n._("Reloaded the configuration")); + } + } + + self.syncAppearance(); + } + + fn propIsActive( + _: *gtk.Window, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Hide quick-terminal if set to autohide + if (self.isQuickTerminal()) { + if (self.getConfig()) |cfg| { + if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } + } + } + + // Don't change urgency if we're not the active window. + if (self.as(gtk.Window).isActive() == 0) return; + + self.winproto().setUrgent(false) catch |err| { + log.warn( + "winproto failed to reset urgency={}", + .{err}, + ); + }; + } + + fn propGdkSurfaceDims( + _: *gdk.Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // X11 needs to fix blurring on resize, but winproto implementations + // could do anything. + self.private().winproto.resizeEvent() catch |err| { + log.warn( + "winproto resize event failed error={}", + .{err}, + ); + }; + } + + fn propFullscreened( + _: *adw.ApplicationWindow, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.syncAppearance(); + } + + fn propMaximized( + _: *adw.ApplicationWindow, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.syncAppearance(); + } + + fn propMenuActive( + button: *gtk.MenuButton, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Debian 12 is stuck on GTK 4.8 + if (!gtk_version.atLeast(4, 10, 0)) return; + + // We only care if we're activating. If we're activating then + // we need to check the validity of our menu items. + const active = button.getActive() != 0; + if (!active) return; + + self.syncActions(); + } + + fn propQuickTerminal( + _: *adw.ApplicationWindow, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + if (priv.surface_init) { + log.warn("quick terminal property can't be changed after surfaces have been initialized", .{}); + return; + } + + if (priv.quick_terminal) { + // Initialize the quick terminal at the app-layer + Application.default().winproto().initQuickTerminal(self) catch |err| { + log.warn("failed to initialize quick terminal error={}", .{err}); + return; + }; + } + } + + fn propScaleFactor( + _: *adw.ApplicationWindow, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // On some platforms (namely X11) we need to refresh our appearance when + // the scale factor changes. In theory this could be more fine-grained as + // a full refresh could be expensive, but a) this *should* be rare, and + // b) quite noticeable visual bugs would occur if this is not present. + self.private().winproto.syncAppearance() catch |err| { + log.warn( + "failed to sync appearance after scale factor has been updated={}", + .{err}, + ); + return; + }; + } + + fn closureTitlebarStyleIsTab( + _: *Self, + value: TitlebarStyle, + ) callconv(.c) c_int { + return @intFromBool(switch (value) { + .native => false, + .tabs => true, + }); + } + + fn closureSubtitle( + _: *Self, + config_: ?*Config, + pwd_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + const config = if (config_) |v| v.get() else return null; + return switch (config.@"window-subtitle") { + .false => null, + .@"working-directory" => pwd: { + const pwd = pwd_ orelse return null; + break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd)); + }, + }; + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.command_palette.set(null); + + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + priv.tab_bindings.setSource(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 priv = self.private(); + priv.tab_bindings.unref(); + priv.winproto.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + fn windowRealize(_: *gtk.Widget, self: *Window) callconv(.c) void { + const app = Application.default(); + + // Initialize our window protocol logic + if (winprotopkg.Window.init( + app.allocator(), + app.winproto(), + self, + )) |wp| { + self.private().winproto = wp; + } else |err| { + log.warn("failed to initialize window protocol error={}", .{err}); + return; + } + + // We need to setup resize notifications on our surface, + // which is only available after the window had been realized. + if (self.as(gtk.Native).getSurface()) |gdk_surface| { + _ = gobject.Object.signals.notify.connect( + gdk_surface, + *Self, + propGdkSurfaceDims, + self, + .{ .detail = "width" }, + ); + _ = gobject.Object.signals.notify.connect( + gdk_surface, + *Self, + propGdkSurfaceDims, + self, + .{ .detail = "height" }, + ); + } + + // When we are realized we always setup our appearance since this + // calls some winproto functions. + self.syncAppearance(); + } + + fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void { + self.performBindingAction(.new_tab); + } + + fn tabOverviewCreateTab( + _: *adw.TabOverview, + self: *Self, + ) callconv(.c) *adw.TabPage { + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab, .none); + } + + fn tabOverviewOpen( + tab_overview: *adw.TabOverview, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We only care about when the tab overview is closed. + if (tab_overview.getOpen() != 0) return; + + // On tab overview close, focus is sometimes lost. This is an + // upstream issue in libadwaita[1]. When this is resolved we + // can put a runtime version check here to avoid this workaround. + // + // Our workaround is to start a timer after 500ms to refocus + // the currently selected tab. We choose 500ms because the adw + // animation is 400ms. + // + // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670 + + // If we have an old timer remove it + const priv = self.private(); + if (priv.tab_overview_focus_timer) |timer| { + _ = glib.Source.remove(timer); + } + + // Restart our timer + priv.tab_overview_focus_timer = glib.timeoutAdd( + 500, + tabOverviewFocusTimer, + self, + ); + } + + fn tabOverviewFocusTimer( + ud: ?*anyopaque, + ) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always note our timer is removed + self.private().tab_overview_focus_timer = null; + + // Get our currently active surface which should respect the newly + // selected tab. Grab focus. + const surface = self.getActiveSurface() orelse return 0; + surface.grabFocus(); + + // Remove the timer + return 0; + } + + fn windowCloseRequest( + _: *gtk.Window, + self: *Self, + ) callconv(.c) c_int { + if (self.getNeedsConfirmQuit()) { + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.window); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Self, + closeConfirmationClose, + self, + .{}, + ); + + // Show it + dialog.present(self.as(gtk.Widget)); + return @intFromBool(true); + } + + self.as(gtk.Window).destroy(); + return @intFromBool(false); + } + + fn closeConfirmationClose( + _: *CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + self.as(gtk.Window).destroy(); + } + + fn closeConfirmationCloseTab( + _: *CloseConfirmationDialog, + page: *adw.TabPage, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + page.getChild().as(gtk.Widget), + ) orelse { + log.warn("close confirmation called for non-existent page", .{}); + return; + }; + tab_view.closePageFinish(page, @intFromBool(true)); + } + + fn closeConfirmationCancelTab( + _: *CloseConfirmationDialog, + page: *adw.TabPage, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + page.getChild().as(gtk.Widget), + ) orelse { + log.warn("close confirmation called for non-existent page", .{}); + return; + }; + tab_view.closePageFinish(page, @intFromBool(false)); + } + + fn tabViewClosePage( + _: *adw.TabView, + page: *adw.TabPage, + self: *Self, + ) callconv(.c) c_int { + const priv = self.private(); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse + return @intFromBool(false); + + // If the tab says it doesn't need confirmation then we go ahead + // and close immediately. + if (!tab.getNeedsConfirmQuit()) { + priv.tab_view.closePageFinish(page, @intFromBool(true)); + return @intFromBool(true); + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.tab); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *adw.TabPage, + closeConfirmationCloseTab, + page, + .{}, + ); + _ = CloseConfirmationDialog.signals.cancel.connect( + dialog, + *adw.TabPage, + closeConfirmationCancelTab, + page, + .{}, + ); + + // Show it + dialog.present(child); + return @intFromBool(true); + } + + fn tabViewSelectedPage( + _: *adw.TabView, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // Always reset our binding source in case we have no pages. + priv.tab_bindings.setSource(null); + + // Get our current page which MUST be a Tab object. + const page = priv.tab_view.getSelectedPage() orelse return; + const child = page.getChild(); + assert(gobject.ext.isA(child, Tab)); + + // Setup our binding group. This ensures things like the title + // are synced from the active tab. + priv.tab_bindings.setSource(child.as(gobject.Object)); + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); + } + + fn tabViewPageAttached( + _: *adw.TabView, + page: *adw.TabPage, + _: c_int, + self: *Self, + ) callconv(.c) void { + // Get the attached page which must be a Tab object. + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse return; + + // Attach listeners for the tab. + _ = Tab.signals.@"close-request".connect( + tab, + *Self, + tabCloseRequest, + self, + .{}, + ); + + // Attach listeners for the surface. + // + // Interesting behavior here that was previously undocumented but + // I'm going to make it explicit here: we accept all the signals here + // (like toggle-fullscreen) regardless of whether the surface or tab + // is focused. At the time of writing this we have no API that could + // really trigger these that way but its theoretically possible. + // + // What is DEFINITELY possible is something like OSC52 triggering + // a clipboard-write signal on an unfocused tab/surface. We definitely + // want to show the user a notification about that but our notification + // right now is a toast that doesn't make it clear WHO used the + // clipboard. We probably want to change that in the future. + // + // I'm not sure how desirable all the above is, and we probably + // should be thoughtful about future signals here. But all of this + // behavior is consistent with macOS and the previous GTK apprt, + // but that behavior was all implicit and not documented, so here + // I am. + if (tab.getSurfaceTree()) |tree| { + self.connectSurfaceHandlers(tree); + } + } + + fn tabViewPageDetached( + _: *adw.TabView, + page: *adw.TabPage, + _: c_int, + self: *Self, + ) callconv(.c) void { + // We need to get the tab to disconnect the signals. + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse return; + _ = gobject.signalHandlersDisconnectMatched( + tab.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + + // Remove the tree handlers + if (tab.getSurfaceTree()) |tree| { + self.disconnectSurfaceHandlers(tree); + } + } + + fn tabViewCreateWindow( + _: *adw.TabView, + _: *Self, + ) callconv(.c) *adw.TabView { + // Create a new window without creating a new tab. + const win = gobject.ext.newInstance( + Self, + .{ + .application = Application.default(), + }, + ); + + // We have to show it otherwise it'll just be hidden. + gtk.Window.present(win.as(gtk.Window)); + + // Get our tab view + return win.private().tab_view; + } + + fn tabCloseRequest( + tab: *Tab, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const page = priv.tab_view.getPage(tab.as(gtk.Widget)); + // TODO: connect close page handler to tab to check for confirmation + priv.tab_view.closePage(page); + } + + fn tabViewNPages( + _: *adw.TabView, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + if (priv.tab_view.getNPages() == 0) { + // If we have no pages left then we want to close window. + + // If the tab overview is open, then we don't close the window + // because its a rather abrupt experience. This also fixes an + // issue where dragging out the last tab in the tab overview + // won't cause Ghostty to exit. + if (priv.tab_overview.getOpen() != 0) return; + + self.as(gtk.Window).close(); + } + } + fn setupTabMenu( + _: *adw.TabView, + page: ?*adw.TabPage, + self: *Self, + ) callconv(.c) void { + self.private().context_menu_page = page; + } + + fn surfaceClipboardWrite( + _: *Surface, + clipboard_type: apprt.Clipboard, + text: [*:0]const u8, + self: *Self, + ) callconv(.c) void { + // We only toast for the standard clipboard. + if (clipboard_type != .standard) return; + + // We only toast if configured to + const priv = self.private(); + const config_obj = priv.config orelse return; + const config = config_obj.get(); + if (!config.@"app-notifications".@"clipboard-copy") { + return; + } + + if (text[0] != 0) + self.addToast(i18n._("Copied to clipboard")) + else + self.addToast(i18n._("Cleared clipboard")); + } + + fn surfaceMenu( + _: *Surface, + self: *Self, + ) callconv(.c) void { + self.syncActions(); + } + + fn surfacePresentRequest( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + // Verify that this surface is actually in this window. + { + const surface_window = ext.getAncestor( + Self, + surface.as(gtk.Widget), + ) orelse { + log.warn( + "present request called for non-existent surface", + .{}, + ); + return; + }; + if (surface_window != self) { + log.warn( + "present request called for surface in different window", + .{}, + ); + return; + } + } + + // Get the tab for this surface. + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("present request surface not found", .{}); + return; + }; + + // Get the page that contains this tab + const priv = self.private(); + const tab_view = priv.tab_view; + const page = tab_view.getPage(tab.as(gtk.Widget)); + tab_view.setSelectedPage(page); + + // Grab focus + surface.grabFocus(); + + // Bring the window to the front. + self.as(gtk.Window).present(); + } + + fn surfaceToggleFullscreen( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + _ = surface; + if (self.as(gtk.Window).isFullscreen() != 0) { + self.as(gtk.Window).unfullscreen(); + } else { + self.as(gtk.Window).fullscreen(); + } + + // We react to the changes in the propFullscreen callback + } + + fn surfaceToggleMaximize( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + _ = surface; + if (self.as(gtk.Window).isMaximized() != 0) { + self.as(gtk.Window).unmaximize(); + } else { + self.as(gtk.Window).maximize(); + } + + // We react to the changes in the propMaximized callback + } + + fn surfaceInit( + surface: *Surface, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // Make sure we init only once + if (priv.surface_init) return; + priv.surface_init = true; + + // Setup our default and minimum size. + if (surface.getDefaultSize()) |size| { + self.as(gtk.Window).setDefaultSize( + @intCast(size.width), + @intCast(size.height), + ); + } + if (surface.getMinSize()) |size| { + self.as(gtk.Widget).setSizeRequest( + @intCast(size.width), + @intCast(size.height), + ); + } + } + + fn tabSplitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + + fn actionAbout( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const name = "Ghostty"; + const icon = "com.mitchellh.ghostty"; + const website = "https://ghostty.org"; + + if (adw_version.supportsDialogs()) { + adw.showAboutDialog( + self.as(gtk.Widget), + "application-name", + name, + "developer-name", + i18n._("Ghostty Developers"), + "application-icon", + icon, + "version", + build_config.version_string.ptr, + "issue-url", + "https://github.com/ghostty-org/ghostty/issues", + "website", + website, + @as(?*anyopaque, null), + ); + } else { + gtk.showAboutDialog( + self.as(gtk.Window), + "program-name", + name, + "logo-icon-name", + icon, + "title", + i18n._("About Ghostty"), + "version", + build_config.version_string.ptr, + "website", + website, + @as(?*anyopaque, null), + ); + } + } + + fn actionClose( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + self.as(gtk.Window).close(); + } + + fn actionCloseTab( + _: *gio.SimpleAction, + param_: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const param = param_ orelse { + log.warn("win.close-tab called without a parameter", .{}); + return; + }; + + var str: ?[*:0]const u8 = null; + param.get("&s", &str); + + const mode = std.meta.stringToEnum( + input.Binding.Action.CloseTabMode, + std.mem.span( + str orelse { + log.warn("invalid mode provided to win.close-tab", .{}); + return; + }, + ), + ) orelse { + log.warn("invalid mode provided to win.close-tab: {s}", .{str.?}); + return; + }; + + self.performBindingAction(.{ .close_tab = mode }); + } + + fn actionNewWindow( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.new_window); + } + + fn actionNewTab( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.new_tab); + } + + fn actionPromptContextTabTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const page = priv.context_menu_page orelse return; + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse return; + tab.promptTabTitle(); + } + + fn actionPromptSurfaceTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_surface_title); + } + + fn actionPromptTabTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_tab_title); + } + + fn actionSplitRight( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .right }); + } + + fn actionSplitLeft( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .left }); + } + + fn actionSplitUp( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .up }); + } + + fn actionSplitDown( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .down }); + } + + fn actionCopy( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .copy_to_clipboard = .mixed }); + } + + fn actionPaste( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.paste_from_clipboard); + } + + fn actionReset( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.reset); + } + + fn actionClear( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.clear_screen); + } + + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return; + + if (config.@"bell-features".system) system: { + const native = self.as(gtk.Native).getSurface() orelse { + log.warn("unable to get native surface from window", .{}); + break :system; + }; + native.beep(); + } + + if (config.@"bell-features".attention) attention: { + // Dont set urgency if the window is already active. + if (self.as(gtk.Window).isActive() != 0) break :attention; + + // Request user attention + self.winproto().setUrgent(true) catch |err| { + log.warn("winproto failed to set urgency={}", .{err}); + }; + } + } + + /// Toggle the command palette. + /// + /// TODO: accept the surface that toggled the command palette as a parameter + fn toggleCommandPalette(self: *Window) void { + const priv = self.private(); + + // Get a reference to a command palette. First check the weak reference + // that we save to see if we already have one stored. If we don't then + // create a new one. + const command_palette = priv.command_palette.get() orelse command_palette: { + // Create a fresh command palette. + const command_palette = CommandPalette.new(); + + // Synchronize our config to the command palette's config. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + command_palette.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Listen to the activate signal to know if the user selected an option in + // the command palette. + _ = CommandPalette.signals.trigger.connect( + command_palette, + *Window, + signalCommandPaletteTrigger, + self, + .{}, + ); + + // Save a weak reference to the command palette. We use a weak reference to avoid + // reference counting cycles that might cause problems later. + priv.command_palette.set(command_palette); + + break :command_palette command_palette; + }; + defer command_palette.unref(); + + // Tell the command palette to toggle itself. If the dialog gets + // presented (instead of hidden) it will be modal over our window. + command_palette.toggle(self); + } + + // React to a signal from a command palette asking an action to be performed. + fn signalCommandPaletteTrigger(_: *CommandPalette, action: *const input.Binding.Action, self: *Self) callconv(.c) void { + // If the activation actually has an action, perform it. + self.performBindingAction(action.*); + } + + /// React to a GTK action requesting that the command palette be toggled. + fn actionToggleCommandPalette( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter + self.toggleCommandPalette(); + } + + /// Toggle the Ghostty inspector for the active surface. + fn toggleInspector(self: *Self) void { + const surface = self.getActiveSurface() orelse return; + _ = surface.controlInspector(.toggle); + } + + /// React to a GTK action requesting that the Ghostty inspector be toggled. + fn actionToggleInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter + self.toggleInspector(); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + 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(DebugWarning); + gobject.ext.ensureType(SplitTree); + gobject.ext.ensureType(Surface); + gobject.ext.ensureType(Tab); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "window", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, + properties.config.impl, + properties.debug.impl, + properties.@"headerbar-visible".impl, + properties.@"quick-terminal".impl, + properties.@"tabs-autohide".impl, + properties.@"tabs-visible".impl, + properties.@"tabs-wide".impl, + properties.@"toolbar-style".impl, + properties.@"titlebar-style".impl, + }); + + // Bindings + class.bindTemplateChildPrivate("tab_overview", .{}); + class.bindTemplateChildPrivate("tab_bar", .{}); + class.bindTemplateChildPrivate("tab_view", .{}); + class.bindTemplateChildPrivate("toolbar", .{}); + class.bindTemplateChildPrivate("toast_overlay", .{}); + + // Template Callbacks + class.bindTemplateCallback("realize", &windowRealize); + class.bindTemplateCallback("new_tab", &btnNewTab); + class.bindTemplateCallback("overview_create_tab", &tabOverviewCreateTab); + class.bindTemplateCallback("overview_notify_open", &tabOverviewOpen); + class.bindTemplateCallback("close_request", &windowCloseRequest); + class.bindTemplateCallback("close_page", &tabViewClosePage); + class.bindTemplateCallback("page_attached", &tabViewPageAttached); + class.bindTemplateCallback("page_detached", &tabViewPageDetached); + class.bindTemplateCallback("setup_tab_menu", &setupTabMenu); + class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow); + class.bindTemplateCallback("notify_n_pages", &tabViewNPages); + class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage); + class.bindTemplateCallback("notify_config", &propConfig); + class.bindTemplateCallback("notify_fullscreened", &propFullscreened); + class.bindTemplateCallback("notify_is_active", &propIsActive); + class.bindTemplateCallback("notify_maximized", &propMaximized); + class.bindTemplateCallback("notify_menu_active", &propMenuActive); + class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); + class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); + class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_subtitle", &closureSubtitle); + + // 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; + }; +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style-dark.css b/vendor/libghostty-vt/src/apprt/gtk/css/style-dark.css new file mode 100644 index 00000000..f13b4f4f --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/css/style-dark.css @@ -0,0 +1,8 @@ +.transparent { + background-color: transparent; +} + +.window .split paned > separator { + background-color: rgba(36, 36, 36, 1); + background-clip: content-box; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style-hc-dark.css b/vendor/libghostty-vt/src/apprt/gtk/css/style-hc-dark.css new file mode 100644 index 00000000..a9aa2dcc --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/css/style-hc-dark.css @@ -0,0 +1,3 @@ +.transparent { + background-color: transparent; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style-hc.css b/vendor/libghostty-vt/src/apprt/gtk/css/style-hc.css new file mode 100644 index 00000000..a9aa2dcc --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/css/style-hc.css @@ -0,0 +1,3 @@ +.transparent { + background-color: transparent; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style.css b/vendor/libghostty-vt/src/apprt/gtk/css/style.css new file mode 100644 index 00000000..9c0f115f --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/css/style.css @@ -0,0 +1,183 @@ +/* Application CSS that applies to the entire application. + * + * This is automatically loaded by AdwApplication: + * https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.3/styles-and-appearance.html#custom-styles + */ + +window.ssd.no-border-radius { + /* Without clearing the border radius, at least on Mutter with + * gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting + * that this will mitigate. + */ + border-radius: 0 0; +} + +/* + * GhosttySurface URL overlay + */ +label.url-overlay { + padding: 4px 8px 4px 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +label.url-overlay:hover { + opacity: 0; +} + +label.url-overlay.left { + border-radius: 0px 6px 0px 0px; +} + +label.url-overlay.right { + border-radius: 6px 0px 0px 0px; +} + +/* + * GhosttySurface search overlay + */ +.search-overlay { + padding: 6px 8px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttySurface key state overlay + */ +.key-state-overlay { + padding: 6px 10px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttySurface resize overlay + */ +label.resize-overlay { + padding: 4px 8px 4px 8px; + border-radius: 6px 6px 6px 6px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + +/* + * GhosttyClipboardConfirmationDialog + * + * Based on boxed-list-separate: + * https://gitlab.gnome.org/GNOME/libadwaita/-/blob/ad446167acf3e6d1ee693f98ca636268be8592a1/src/stylesheet/widgets/_lists.scss#L548 + */ +.clipboard-confirmation-dialog list { + background: none; +} + +.clipboard-confirmation-dialog list > row { + border: none; + margin-bottom: 12px; +} + +.clipboard-confirmation-dialog list > row:last-child { + margin-bottom: 0; +} + +.clipboard-confirmation-dialog .clipboard-overlay { + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents { + filter: blur(0px); + transition: filter 0.3s ease; + border-radius: 10px; +} + +.clipboard-confirmation-dialog .clipboard-contents.blurred { + filter: blur(5px); +} + +/* + * Child Exited Overlay + */ +.child-exited.normal revealer widget { + background-color: rgba(38, 162, 105, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */ +} + +.child-exited.abnormal revealer widget { + background-color: rgba(192, 28, 40, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ +} + +/* + * Surface + */ +.surface progressbar.error trough progress { + background-color: rgba(192, 28, 40, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ +} + +.surface .bell-overlay { + border-color: rgba(58, 148, 74, 0.5); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */ + border-width: 3px; + border-style: solid; +} + +.surface .readonly_overlay { + /* Should be the equivalent of the following SwiftUI color: */ + /* Color(hue: 0.08, saturation: 0.5, brightness: 0.8) */ + color: hsl(25 50 75); + padding: 8px 8px 8px 8px; + margin: 8px 8px 8px 8px; + border-radius: 6px 6px 6px 6px; + outline-style: solid; + outline-width: 1px; +} +/* + * Command Palette + */ +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} + +/* + * Splits + */ + +.window .split paned > separator { + background-color: rgba(250, 250, 250, 1); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--window-bg-color), transparent 0%); */ + background-clip: content-box; + + /* This works around the oversized drag area for the right side of GtkPaned. + * + * Upstream Gtk issue: + * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 + * + * Ghostty issue: + * https://github.com/ghostty-org/ghostty/issues/3020 + * + * Without this, it's not possible to select the first character on the + * right-hand side of a split. + */ + margin: 0; + padding: 0; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ext.zig b/vendor/libghostty-vt/src/apprt/gtk/ext.zig new file mode 100644 index 00000000..df9ab4ea --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ext.zig @@ -0,0 +1,70 @@ +//! Extensions/helpers for GTK objects, following a similar naming +//! style to zig-gobject. These should, wherever possible, be Zig-friendly +//! wrappers around existing GTK functionality, rather than complex new +//! helpers. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const testing = std.testing; + +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +pub const actions = @import("ext/actions.zig"); +const slice = @import("ext/slice.zig"); +pub const StringList = slice.StringList; + +/// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`. +pub fn boxedCopy(comptime T: type, ptr: *const T) *T { + const copy = gobject.boxedCopy(T.getGObjectType(), ptr); + return @ptrCast(@alignCast(copy)); +} + +/// Wrapper around `gobject.boxedFree` to free a boxed type `T`. +pub fn boxedFree(comptime T: type, ptr: ?*T) void { + if (ptr) |p| gobject.boxedFree( + T.getGObjectType(), + p, + ); +} + +/// A wrapper around `glib.List.findCustom` to find an element in the list. +/// The type `T` must be the guaranteed type of every list element. +pub fn listFind( + comptime T: type, + list: *glib.List, + comptime func: *const fn (*T) bool, +) ?*T { + const elem_: ?*glib.List = list.findCustom(null, struct { + fn callback(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int { + const ptr = data orelse return 1; + const v: *T = @ptrCast(@alignCast(@constCast(ptr))); + return if (func(v)) 0 else 1; + } + }.callback); + const elem = elem_ orelse return null; + return @ptrCast(@alignCast(elem.f_data)); +} + +/// Wrapper around `gtk.Widget.getAncestor` to get the widget ancestor +/// of the given type `T`, or null if it doesn't exist. +pub fn getAncestor(comptime T: type, widget: *gtk.Widget) ?*T { + const ancestor_ = widget.getAncestor(gobject.ext.typeFor(T)); + const ancestor = ancestor_ orelse return null; + // We can assert the unwrap because getAncestor above + return gobject.ext.cast(T, ancestor).?; +} + +/// Check a gobject.Value to see what type it is wrapping. This is equivalent to GTK's +/// `G_VALUE_HOLDS()` macro but Zig's C translator does not like it. +pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { + const value = value_ orelse return false; + if (value.f_g_type == g_type) return true; + return gobject.typeCheckValueHolds(value, g_type) != 0; +} + +test { + _ = actions; + _ = slice; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig new file mode 100644 index 00000000..3232bc18 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig @@ -0,0 +1,198 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; +const testing = std.testing; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gValueHolds = @import("../ext.zig").gValueHolds; + +/// Check that an action name is valid. +/// +/// Reimplementation of `g_action_name_is_valid()` so that it can be +/// used at comptime. +/// +/// See: +/// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html +fn gActionNameIsValid(name: [:0]const u8) bool { + if (name.len == 0) return false; + + for (name) |c| switch (c) { + '-' => continue, + '.' => continue, + '0'...'9' => continue, + 'a'...'z' => continue, + 'A'...'Z' => continue, + else => return false, + }; + + return true; +} + +test "gActionNameIsValid" { + try testing.expect(gActionNameIsValid("ring-bell")); + try testing.expect(!gActionNameIsValid("ring_bell")); +} + +/// Function to create a structure for describing an action. +pub fn Action(comptime T: type) type { + return struct { + const Self = @This(); + pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void; + + name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + state: ?*glib.Variant = null, + + /// Function to initialize a new action so that we can comptime check + /// the name. + pub fn init( + comptime name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + ) Self { + comptime assert(gActionNameIsValid(name)); + + return .{ + .name = name, + .callback = callback, + .parameter_type = parameter_type, + }; + } + + /// Function to initialize a new stateful action so that we can comptime + /// check the name. + pub fn initStateful( + comptime name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + state: *glib.Variant, + ) Self { + comptime assert(gActionNameIsValid(name)); + return .{ + .name = name, + .callback = callback, + .parameter_type = parameter_type, + .state = state, + }; + } + }; +} + +/// Add actions to a widget that implements gio.ActionMap. +pub fn add(comptime T: type, self: *T, actions: []const Action(T)) void { + addToMap(T, self, self.as(gio.ActionMap), actions); +} + +/// Add actions to the given map. +pub fn addToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void { + for (actions) |entry| { + assert(gActionNameIsValid(entry.name)); + const action = action: { + if (entry.state) |state| { + break :action gio.SimpleAction.newStateful( + entry.name, + entry.parameter_type, + state, + ); + } + break :action gio.SimpleAction.new( + entry.name, + entry.parameter_type, + ); + }; + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *T, + entry.callback, + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } +} + +/// Add actions to a widget that doesn't implement ActionGroup directly. +pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) *gio.SimpleActionGroup { + comptime assert(gActionNameIsValid(name)); + + // Collect our actions into a group since we're just a plain widget that + // doesn't implement ActionGroup directly. + const group = gio.SimpleActionGroup.new(); + errdefer group.unref(); + + addToMap(T, self, group.as(gio.ActionMap), actions); + + self.as(gtk.Widget).insertActionGroup( + name, + group.as(gio.ActionGroup), + ); + + return group; +} + +test "adding actions to an object" { + // This test requires a connection to an active display environment. + if (gtk.initCheck() == 0) return error.SkipZigTest; + + _ = glib.MainContext.acquire(null); + defer glib.MainContext.release(null); + + const callbacks = struct { + fn callback(_: *gio.SimpleAction, variant_: ?*glib.Variant, self: *gtk.Box) callconv(.c) void { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const variant = variant_ orelse return; + assert(variant.isOfType(i32_variant_type) != 0); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + value.setInt(variant.getInt32()); + + self.as(gobject.Object).setProperty("spacing", &value); + } + }; + + const box = gtk.Box.new(.vertical, 0); + _ = box.as(gobject.Object).refSink(); + defer box.unref(); + + { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const actions = [_]Action(gtk.Box){ + .init("test", callbacks.callback, i32_variant_type), + }; + + _ = addAsGroup(gtk.Box, box, "test", &actions); + } + + const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + const parameter = glib.Variant.newInt32(expected); + + try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); + + _ = glib.MainContext.iteration(null, @intFromBool(true)); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + box.as(gobject.Object).getProperty("spacing", &value); + + try testing.expect(gValueHolds(&value, gobject.ext.types.int)); + + const actual = value.getInt(); + try testing.expectEqual(expected, actual); + + while (glib.MainContext.iteration(null, 0) != 0) {} +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ext/slice.zig b/vendor/libghostty-vt/src/apprt/gtk/ext/slice.zig new file mode 100644 index 00000000..49ad63d8 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ext/slice.zig @@ -0,0 +1,111 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const glib = @import("glib"); +const gobject = @import("gobject"); + +/// A boxed type that holds a list of string slices. +pub const StringList = struct { + arena: ArenaAllocator, + strings: []const [:0]const u8, + + pub fn create( + alloc: Allocator, + strings: []const [:0]const u8, + ) Allocator.Error!*StringList { + var arena: ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const arena_alloc = arena.allocator(); + var stored = try arena_alloc.alloc([:0]const u8, strings.len); + for (strings, 0..) |s, i| stored[i] = try arena_alloc.dupeZ(u8, s); + + const ptr = try alloc.create(StringList); + errdefer alloc.destroy(ptr); + ptr.* = .{ .arena = arena, .strings = stored }; + + return ptr; + } + + pub fn deinit(self: *StringList) void { + self.arena.deinit(); + } + + pub fn destroy(self: *StringList) void { + const alloc = self.arena.child_allocator; + self.deinit(); + alloc.destroy(self); + } + + /// Returns the general-purpose allocator used by this StringList. + pub fn allocator(self: *const StringList) Allocator { + return self.arena.child_allocator; + } + + pub const getGObjectType = gobject.ext.defineBoxed( + StringList, + .{ + .name = "GhosttyStringList", + .funcs = .{ + .copy = &struct { + fn copy(self: *StringList) callconv(.c) *StringList { + return StringList.create( + self.arena.child_allocator, + self.strings, + ) catch @panic("OOM"); + } + }.copy, + .free = &struct { + fn free(self: *StringList) callconv(.c) void { + self.destroy(); + } + }.free, + }, + }, + ); +}; + +test "StringList create and destroy" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "hello", "world" }; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 2), list.strings.len); + try testing.expectEqualStrings("hello", list.strings[0]); + try testing.expectEqualStrings("world", list.strings[1]); +} + +test "StringList create empty list" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{}; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 0), list.strings.len); +} + +test "StringList boxedCopy and boxedFree" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "foo", "bar", "baz" }; + const original = try StringList.create(alloc, input); + defer original.destroy(); + + const copied: *StringList = @ptrCast(@alignCast(gobject.boxedCopy( + StringList.getGObjectType(), + original, + ))); + defer gobject.boxedFree(StringList.getGObjectType(), copied); + + try testing.expectEqual(@as(usize, 3), copied.strings.len); + try testing.expectEqualStrings("foo", copied.strings[0]); + try testing.expectEqualStrings("bar", copied.strings[1]); + try testing.expectEqualStrings("baz", copied.strings[2]); + + try testing.expect(original.strings.ptr != copied.strings.ptr); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/flatpak.zig b/vendor/libghostty-vt/src/apprt/gtk/flatpak.zig new file mode 100644 index 00000000..dc47c671 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/flatpak.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_config = @import("../../build_config.zig"); +const internal_os = @import("../../os/main.zig"); +const glib = @import("glib"); + +pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir { + if (comptime build_config.flatpak) { + // Only consult Flatpak runtime data for host case. + if (internal_os.isFlatpak()) { + var result: internal_os.ResourcesDir = .{ + .app_path = try alloc.dupe(u8, "/app/share/ghostty"), + }; + errdefer alloc.free(result.app_path.?); + + const keyfile = glib.KeyFile.new(); + defer keyfile.unref(); + + if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result; + const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result; + defer glib.free(app_dir.ptr); + + result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" }); + return result; + } + } + + return try internal_os.resourcesDir(alloc); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/gsettings.zig b/vendor/libghostty-vt/src/apprt/gtk/gsettings.zig new file mode 100644 index 00000000..8cf7f12d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/gsettings.zig @@ -0,0 +1,101 @@ +const std = @import("std"); +const gtk = @import("gtk"); +const gobject = @import("gobject"); + +/// GTK Settings keys with well-defined types. +pub const Key = enum { + @"gtk-enable-primary-paste", + @"gtk-xft-dpi", + @"gtk-font-name", + + fn Type(comptime self: Key) type { + return switch (self) { + .@"gtk-enable-primary-paste" => bool, + .@"gtk-xft-dpi" => c_int, + .@"gtk-font-name" => []const u8, + }; + } + + fn GValueType(comptime self: Key) type { + return switch (self.Type()) { + bool => c_int, + c_int => c_int, + []const u8 => ?[*:0]const u8, + else => @compileError("Unsupported type for GTK settings"), + }; + } + + /// Returns true if this setting type requires memory allocation. + /// Types that do not need allocation must be explicitly marked. + fn requiresAllocation(comptime self: Key) bool { + const T = self.Type(); + return switch (T) { + bool, c_int => false, + else => true, + }; + } +}; + +/// Reads a GTK setting for non-allocating types. +/// Automatically uses XDG Desktop Portal in Flatpak environments. +/// Returns null if the setting is unavailable. +pub fn get(comptime key: Key) ?key.Type() { + if (comptime key.requiresAllocation()) { + @compileError("Allocating types require an allocator; use getAlloc() instead"); + } + const settings = gtk.Settings.getDefault() orelse return null; + return getImpl(settings, null, key) catch unreachable; +} + +/// Reads a GTK setting, allocating memory if necessary. +/// Automatically uses XDG Desktop Portal in Flatpak environments. +/// Caller must free returned memory with the provided allocator. +/// Returns null if the setting is unavailable. +pub fn getAlloc(allocator: std.mem.Allocator, comptime key: Key) !?key.Type() { + const settings = gtk.Settings.getDefault() orelse return null; + return getImpl(settings, allocator, key); +} + +fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: Key) !?key.Type() { + const GValType = key.GValueType(); + var value = gobject.ext.Value.new(GValType); + defer value.unset(); + + settings.as(gobject.Object).getProperty(@tagName(key).ptr, &value); + + return switch (key.Type()) { + bool => value.getInt() != 0, + c_int => value.getInt(), + []const u8 => blk: { + const alloc = allocator.?; + const ptr = value.getString() orelse break :blk null; + const str = std.mem.span(ptr); + break :blk try alloc.dupe(u8, str); + }, + else => @compileError("Unsupported type for GTK settings"), + }; +} + +test "Key.Type returns correct types" { + try std.testing.expectEqual(bool, Key.@"gtk-enable-primary-paste".Type()); + try std.testing.expectEqual(c_int, Key.@"gtk-xft-dpi".Type()); + try std.testing.expectEqual([]const u8, Key.@"gtk-font-name".Type()); +} + +test "Key.requiresAllocation identifies allocating types" { + try std.testing.expectEqual(false, Key.@"gtk-enable-primary-paste".requiresAllocation()); + try std.testing.expectEqual(false, Key.@"gtk-xft-dpi".requiresAllocation()); + try std.testing.expectEqual(true, Key.@"gtk-font-name".requiresAllocation()); +} + +test "Key.GValueType returns correct GObject types" { + try std.testing.expectEqual(c_int, Key.@"gtk-enable-primary-paste".GValueType()); + try std.testing.expectEqual(c_int, Key.@"gtk-xft-dpi".GValueType()); + try std.testing.expectEqual(?[*:0]const u8, Key.@"gtk-font-name".GValueType()); +} + +test "@tagName returns correct GTK property names" { + try std.testing.expectEqualStrings("gtk-enable-primary-paste", @tagName(Key.@"gtk-enable-primary-paste")); + try std.testing.expectEqualStrings("gtk-xft-dpi", @tagName(Key.@"gtk-xft-dpi")); + try std.testing.expectEqualStrings("gtk-font-name", @tagName(Key.@"gtk-font-name")); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig new file mode 100644 index 00000000..71edb076 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig @@ -0,0 +1,140 @@ +const std = @import("std"); + +// Until the gobject bindings are built at the same time we are building +// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version +// macros match the version of `gtk4` that we are building/linking against. +const c = @cImport({ + @cInclude("gtk/gtk.h"); +}); + +const gtk = @import("gtk"); + +const log = std.log.scoped(.gtk); + +pub const comptime_version: std.SemanticVersion = .{ + .major = c.GTK_MAJOR_VERSION, + .minor = c.GTK_MINOR_VERSION, + .patch = c.GTK_MICRO_VERSION, +}; + +pub fn getRuntimeVersion() std.SemanticVersion { + return .{ + .major = gtk.getMajorVersion(), + .minor = gtk.getMinorVersion(), + .patch = gtk.getMicroVersion(), + }; +} + +pub fn logVersion() void { + log.info("GTK version build={f} runtime={f}", .{ + comptime_version, + getRuntimeVersion(), + }); +} + +/// Verifies that the GTK version is at least the given version. +/// +/// This can be run in both a comptime and runtime context. If it is run in a +/// comptime context, it will only check the version in the headers. If it is +/// run in a runtime context, it will check the actual version of the library we +/// are linked against. +/// +/// This function should be used in cases where the version check would affect +/// code generation, such as using symbols that are only available beyond a +/// certain version. For checks which only depend on GTK's runtime behavior, +/// use `runtimeAtLeast`. +/// +/// This is inlined so that the comptime checks will disable the runtime checks +/// if the comptime checks fail. +pub inline fn atLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + // If our header has lower versions than the given version, + // we can return false immediately. This prevents us from + // compiling against unknown symbols and makes runtime checks + // very slightly faster. + if (comptime comptime_version.order(.{ + .major = major, + .minor = minor, + .patch = micro, + }) == .lt) return false; + + // If we're in comptime then we can't check the runtime version. + if (@inComptime()) return true; + + return runtimeAtLeast(major, minor, micro); +} + +/// Verifies that the GTK version at runtime is at least the given version. +/// +/// This function should be used in cases where the only the runtime behavior +/// is affected by the version check. For checks which would affect code +/// generation, use `atLeast`. +pub inline fn runtimeAtLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + // We use the functions instead of the constants such as c.GTK_MINOR_VERSION + // because the function gets the actual runtime version. + const runtime_version = getRuntimeVersion(); + return runtime_version.order(.{ + .major = major, + .minor = minor, + .patch = micro, + }) != .lt; +} + +pub inline fn runtimeUntil( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + const runtime_version = getRuntimeVersion(); + return runtime_version.order(.{ + .major = major, + .minor = minor, + .patch = micro, + }) == .lt; +} + +test "atLeast" { + const testing = std.testing; + + const funs = &.{ atLeast, runtimeAtLeast }; + inline for (funs) |fun| { + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } +} + +test "runtimeUntil" { + const testing = std.testing; + + // This is an array in case we add a comptime variant. + const funs = &.{runtimeUntil}; + inline for (funs) |fun| { + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig new file mode 100644 index 00000000..fa4a6723 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig @@ -0,0 +1,196 @@ +//! DBus helper for IPC +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gio = @import("gio"); +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const ApprtApp = @import("../App.zig"); + +/// The target for this IPC. +target: apprt.ipc.Target, + +/// Connection to the DBus session bus. +dbus: *gio.DBusConnection, + +/// The bus name of the Ghostty instance that we are calling. +bus_name: [:0]const u8, + +/// The object path of the Ghostty instance that we are calling. +object_path: [:0]const u8, + +/// Used to build the DBus payload. +payload_builder: *glib.VariantBuilder, + +/// Used to build the parameters for the IPC. +parameters_builder: *glib.VariantBuilder, + +/// Initialize the helper. +pub fn init(alloc: Allocator, target: apprt.ipc.Target, action: [:0]const u8) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!Self { + var buf: [256]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&buf); + const stderr = &stderr_writer.interface; + + // Get the appropriate bus name and object path for contacting the + // Ghostty instance we're interested in. + const bus_name: [:0]const u8, const object_path: [:0]const u8 = switch (target) { + .class => |class| result: { + // Force the usage of the class specified on the CLI to determine the + // bus name and object path. + const object_path = try std.fmt.allocPrintSentinel(alloc, "/{s}", .{class}, 0); + + std.mem.replaceScalar(u8, object_path, '.', '/'); + std.mem.replaceScalar(u8, object_path, '-', '_'); + + break :result .{ class, object_path }; + }, + .detect => .{ ApprtApp.application_id, ApprtApp.object_path }, + }; + errdefer { + switch (target) { + .class => alloc.free(object_path), + .detect => {}, + } + } + + if (gio.Application.idIsValid(bus_name.ptr) == 0) { + try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name}); + try stderr.flush(); + return error.IPCFailed; + } + + if (glib.Variant.isObjectPath(object_path.ptr) == 0) { + try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path}); + try stderr.flush(); + return error.IPCFailed; + } + + // Get a connection to the DBus session bus. + const dbus = dbus: { + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const dbus_ = gio.busGetSync(.session, null, &err_); + if (err_) |err| { + try stderr.print( + "Unable to establish connection to D-Bus session bus: {s}\n", + .{err.f_message orelse "(unknown)"}, + ); + try stderr.flush(); + return error.IPCFailed; + } + + break :dbus dbus_ orelse { + try stderr.print("gio.busGetSync returned null\n", .{}); + try stderr.flush(); + return error.IPCFailed; + }; + }; + + // Set up the payload builder. + const payload_variant_type = glib.VariantType.new("(sava{sv})"); + defer glib.free(payload_variant_type); + + const payload_builder = glib.VariantBuilder.new(payload_variant_type); + + // Add the action name to the payload. + { + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + const bytes = glib.Bytes.new(action.ptr, action.len + 1); + defer bytes.unref(); + const value = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + + payload_builder.addValue(value); + } + + // Set up the parameter builder. + const parameters_variant_type = glib.VariantType.new("av"); + defer parameters_variant_type.free(); + + const parameters_builder = glib.VariantBuilder.new(parameters_variant_type); + + return .{ + .target = target, + .dbus = dbus, + .bus_name = bus_name, + .object_path = object_path, + .payload_builder = payload_builder, + .parameters_builder = parameters_builder, + }; +} + +/// Add a parameter to the IPC call. +pub fn addParameter(self: *Self, variant: *glib.Variant) void { + self.parameters_builder.add("v", variant); +} + +/// Send the IPC to the remote Ghostty. Once it completes, nothing further +/// should be done with this object other than call `deinit`. +pub fn send(self: *Self) (std.Io.Writer.Error || apprt.ipc.Errors)!void { + var buf: [256]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&buf); + const stderr = &stderr_writer.interface; + + // finish building the parameters + const parameters = self.parameters_builder.end(); + + // Add the parameters to the payload. + self.payload_builder.addValue(parameters); + + // Add the platform data to the payload. + { + const platform_data_variant_type = glib.VariantType.new("a{sv}"); + defer platform_data_variant_type.free(); + + self.payload_builder.open(platform_data_variant_type); + defer self.payload_builder.close(); + + // We have no platform data. + } + + const payload = self.payload_builder.end(); + + { + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const result_ = self.dbus.callSync( + self.bus_name, + self.object_path, + "org.gtk.Actions", + "Activate", + payload, + null, // We don't care about the return type, we don't do anything with it. + .{}, // no flags + -1, // default timeout + null, // not cancellable + &err_, + ); + defer if (result_) |result| result.unref(); + + if (err_) |err| { + try stderr.print( + "D-Bus method call returned an error err={s}\n", + .{err.f_message orelse "(unknown)"}, + ); + try stderr.flush(); + return error.IPCFailed; + } + } +} + +/// Free/unref any data held by this instance. +pub fn deinit(self: *Self, alloc: Allocator) void { + switch (self.target) { + .class => alloc.free(self.object_path), + .detect => {}, + } + self.parameters_builder.unref(); + self.payload_builder.unref(); + self.dbus.unref(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/new_window.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_window.zig new file mode 100644 index 00000000..02fed322 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_window.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); + +// Use a D-Bus method call to open a new window on GTK. +// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI +// +// `ghostty +new-window` is equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] [] +// ``` +// +// `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["-e" "echo" "hello"]>]' [] +// ``` +pub fn newWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { + var dbus = try DBus.init( + alloc, + target, + if (value.arguments == null) + "new-window" + else + "new-window-command", + ); + defer dbus.deinit(alloc); + + if (value.arguments) |arguments| { + // If any arguments were specified on the command line, the first + // parameter is an array of strings that contain the arguments. They + // will be sent to the main Ghostty instance and interpreted as CLI + // arguments. + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var command: glib.VariantBuilder = undefined; + command.init(as_variant_type); + errdefer command.clear(); + + for (arguments) |argument| { + const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); + defer bytes.unref(); + const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + command.addValue(string); + } + + dbus.addParameter(command.end()); + } + + try dbus.send(); + + return true; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/key.zig b/vendor/libghostty-vt/src/apprt/gtk/key.zig new file mode 100644 index 00000000..5f717e14 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/key.zig @@ -0,0 +1,535 @@ +const std = @import("std"); + +const gdk = @import("gdk"); +const glib = @import("glib"); +const gtk = @import("gtk"); + +const input = @import("../../input.zig"); +const winproto = @import("winproto.zig"); + +/// Returns a GTK accelerator string from a trigger. +pub fn accelFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{WriteFailed}!?[:0]const u8 { + var writer: std.Io.Writer = .fixed(buf); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll("<Shift>"); + if (trigger.mods.ctrl) try writer.writeAll("<Ctrl>"); + if (trigger.mods.alt) try writer.writeAll("<Alt>"); + if (trigger.mods.super) try writer.writeAll("<Super>"); + + // Write our key + if (!try writeTriggerKey(&writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = writer.buffered(); + return slice[0 .. slice.len - 1 :0]; +} + +/// Returns a XDG-compliant shortcuts string from a trigger. +/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ +pub fn xdgShortcutFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{WriteFailed}!?[:0]const u8 { + var writer: std.Io.Writer = .fixed(buf); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll("SHIFT+"); + if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); + if (trigger.mods.alt) try writer.writeAll("ALT+"); + if (trigger.mods.super) try writer.writeAll("LOGO+"); + + // Write our key + // NOTE: While the spec specifies that only libxkbcommon keysyms are + // expected, using GTK's keysyms should still work as they are identical + // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). + // I haven't been able to any evidence to back up that assumption but + // this works for now + if (!try writeTriggerKey(&writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = writer.buffered(); + return slice[0 .. slice.len - 1 :0]; +} + +fn writeTriggerKey( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) error{WriteFailed}!bool { + switch (trigger.key) { + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); + }, + + .unicode => |cp| { + if (gdk.keyvalName(cp)) |name| { + try writer.writeAll(std.mem.span(name)); + } else { + try writer.print("{u}", .{cp}); + } + }, + + .catch_all => return false, + } + + return true; +} + +pub fn translateMods(state: gdk.ModifierType) input.Mods { + return .{ + .shift = state.shift_mask, + .ctrl = state.control_mask, + .alt = state.alt_mask, + .super = state.super_mask, + // Lock is dependent on the X settings but we just assume caps lock. + .caps_lock = state.lock_mask, + }; +} + +// Get the unshifted unicode value of the keyval. This is used +// by the Kitty keyboard protocol. +pub fn keyvalUnicodeUnshifted( + widget: *gtk.Widget, + event: *gdk.KeyEvent, + keycode: u32, +) u21 { + const display = widget.getDisplay(); + + // We need to get the currently active keyboard layout so we know + // what group to look at. + const layout = event.getLayout(); + + // Get all the possible keyboard mappings for this keycode. A keycode is the + // physical key pressed. + var keys: [*]gdk.KeymapKey = undefined; + var keyvals: [*]c_uint = undefined; + var n_entries: c_int = 0; + if (display.mapKeycode(keycode, &keys, &keyvals, &n_entries) == 0) return 0; + + defer glib.free(keys); + defer glib.free(keyvals); + + // debugging: + // std.log.debug("layout={}", .{layout}); + // for (0..@intCast(n_entries)) |i| { + // std.log.debug("keymap key={} codepoint={x}", .{ + // keys[i], + // gdk.keyvalToUnicode(keyvals[i]), + // }); + // } + + for (0..@intCast(n_entries)) |i| { + if (keys[i].f_group == layout and + keys[i].f_level == 0) + { + return std.math.cast( + u21, + gdk.keyvalToUnicode(keyvals[i]), + ) orelse 0; + } + } + + return 0; +} + +/// Returns the mods to use a key event from a GTK event. +/// This requires a lot of context because the GdkEvent +/// doesn't contain enough on its own. +pub fn eventMods( + event: *gdk.Event, + physical_key: input.Key, + gtk_mods: gdk.ModifierType, + action: input.Action, + app_winproto: *winproto.App, +) input.Mods { + const device = event.getDevice(); + + var mods = app_winproto.eventMods(device, gtk_mods); + mods.num_lock = if (device) |d| d.getNumLockState() != 0 else false; + + // We use the physical key to determine sided modifiers. As + // far as I can tell there's no other way to reliably determine + // this. + // + // We also set the main modifier to true if either side is true, + // since on both X11/Wayland, GTK doesn't set the main modifier + // if only the modifier key is pressed, but our core logic + // relies on it. + switch (physical_key) { + .shift_left => { + mods.shift = action != .release; + mods.sides.shift = .left; + }, + + .shift_right => { + mods.shift = action != .release; + mods.sides.shift = .right; + }, + + .control_left => { + mods.ctrl = action != .release; + mods.sides.ctrl = .left; + }, + + .control_right => { + mods.ctrl = action != .release; + mods.sides.ctrl = .right; + }, + + .alt_left => { + mods.alt = action != .release; + mods.sides.alt = .left; + }, + + .alt_right => { + mods.alt = action != .release; + mods.sides.alt = .right; + }, + + .meta_left => { + mods.super = action != .release; + mods.sides.super = .left; + }, + + .meta_right => { + mods.super = action != .release; + mods.sides.super = .right; + }, + + else => {}, + } + + return mods; +} + +/// Returns an input key from a keyval or null if we don't have a mapping. +pub fn keyFromKeyval(keyval: c_uint) ?input.Key { + for (keymap) |entry| { + if (entry[0] == keyval) return entry[1]; + } + + return null; +} + +/// Returns a keyval from an input key or null if we don't have a mapping. +pub fn keyvalFromKey(key: input.Key) ?c_uint { + switch (key) { + inline else => |key_comptime| { + return comptime value: { + @setEvalBranchQuota(50_000); + for (keymap) |entry| { + if (entry[1] == key_comptime) break :value entry[0]; + } + + break :value null; + }; + }, + } +} + +/// Converts a trigger to a human-readable label for display in UI. +/// +/// Uses GTK accelerator-style formatting (e.g., "Ctrl+Shift+A"). +/// Returns false if the trigger cannot be formatted (e.g., catch_all). +pub fn labelFromTrigger( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) std.Io.Writer.Error!bool { + // Modifiers first, using human-readable format + if (trigger.mods.super) try writer.writeAll("Super+"); + if (trigger.mods.ctrl) try writer.writeAll("Ctrl+"); + if (trigger.mods.alt) try writer.writeAll("Alt+"); + if (trigger.mods.shift) try writer.writeAll("Shift+"); + + // Write the key + return writeTriggerKeyLabel(writer, trigger); +} + +/// Writes the key portion of a trigger in human-readable format. +fn writeTriggerKeyLabel( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) error{WriteFailed}!bool { + switch (trigger.key) { + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + const name = gdk.keyvalName(keyval) orelse return false; + // Capitalize the first letter for nicer display + const span = std.mem.span(name); + if (span.len > 0) { + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + }, + + .unicode => |cp| { + // Try to get a nice name from GDK first + if (gdk.keyvalName(cp)) |name| { + const span = std.mem.span(name); + if (span.len > 0) { + // Capitalize the first letter for nicer display + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + } else { + // Fall back to printing the character + try writer.print("{u}", .{cp}); + } + }, + + .catch_all => return false, + } + + return true; +} + +test "accelFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("<Super>q", (try accelFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })).?); + + try testing.expectEqualStrings("<Shift><Ctrl><Alt><Super>backslash", (try accelFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + +test "xdgShortcutFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })).?); + + try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + +test "labelFromTrigger" { + const testing = std.testing; + + // Simple unicode key with modifier + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })); + try testing.expectEqualStrings("Super+Q", buf.written()); + } + + // Multiple modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })); + try testing.expectEqualStrings("Super+Ctrl+Alt+Shift+Backslash", buf.written()); + } + + // Physical key + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true }, + .key = .{ .physical = .key_a }, + })); + try testing.expectEqualStrings("Ctrl+A", buf.written()); + } + + // No modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .{ .physical = .escape }, + })); + try testing.expectEqualStrings("Escape", buf.written()); + } + + // catch_all returns false + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(!try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .catch_all, + })); + } +} + +/// A raw entry in the keymap. Our keymap contains mappings between +/// GDK keys and our own key enum. +const RawEntry = struct { c_uint, input.Key }; + +const keymap: []const RawEntry = &.{ + .{ gdk.KEY_a, .key_a }, + .{ gdk.KEY_b, .key_b }, + .{ gdk.KEY_c, .key_c }, + .{ gdk.KEY_d, .key_d }, + .{ gdk.KEY_e, .key_e }, + .{ gdk.KEY_f, .key_f }, + .{ gdk.KEY_g, .key_g }, + .{ gdk.KEY_h, .key_h }, + .{ gdk.KEY_i, .key_i }, + .{ gdk.KEY_j, .key_j }, + .{ gdk.KEY_k, .key_k }, + .{ gdk.KEY_l, .key_l }, + .{ gdk.KEY_m, .key_m }, + .{ gdk.KEY_n, .key_n }, + .{ gdk.KEY_o, .key_o }, + .{ gdk.KEY_p, .key_p }, + .{ gdk.KEY_q, .key_q }, + .{ gdk.KEY_r, .key_r }, + .{ gdk.KEY_s, .key_s }, + .{ gdk.KEY_t, .key_t }, + .{ gdk.KEY_u, .key_u }, + .{ gdk.KEY_v, .key_v }, + .{ gdk.KEY_w, .key_w }, + .{ gdk.KEY_x, .key_x }, + .{ gdk.KEY_y, .key_y }, + .{ gdk.KEY_z, .key_z }, + + .{ gdk.KEY_0, .digit_0 }, + .{ gdk.KEY_1, .digit_1 }, + .{ gdk.KEY_2, .digit_2 }, + .{ gdk.KEY_3, .digit_3 }, + .{ gdk.KEY_4, .digit_4 }, + .{ gdk.KEY_5, .digit_5 }, + .{ gdk.KEY_6, .digit_6 }, + .{ gdk.KEY_7, .digit_7 }, + .{ gdk.KEY_8, .digit_8 }, + .{ gdk.KEY_9, .digit_9 }, + + .{ gdk.KEY_semicolon, .semicolon }, + .{ gdk.KEY_space, .space }, + .{ gdk.KEY_apostrophe, .quote }, + .{ gdk.KEY_comma, .comma }, + .{ gdk.KEY_grave, .backquote }, + .{ gdk.KEY_period, .period }, + .{ gdk.KEY_slash, .slash }, + .{ gdk.KEY_minus, .minus }, + .{ gdk.KEY_equal, .equal }, + .{ gdk.KEY_bracketleft, .bracket_left }, + .{ gdk.KEY_bracketright, .bracket_right }, + .{ gdk.KEY_backslash, .backslash }, + + .{ gdk.KEY_Up, .arrow_up }, + .{ gdk.KEY_Down, .arrow_down }, + .{ gdk.KEY_Right, .arrow_right }, + .{ gdk.KEY_Left, .arrow_left }, + .{ gdk.KEY_Home, .home }, + .{ gdk.KEY_End, .end }, + .{ gdk.KEY_Insert, .insert }, + .{ gdk.KEY_Delete, .delete }, + .{ gdk.KEY_Caps_Lock, .caps_lock }, + .{ gdk.KEY_Scroll_Lock, .scroll_lock }, + .{ gdk.KEY_Num_Lock, .num_lock }, + .{ gdk.KEY_Page_Up, .page_up }, + .{ gdk.KEY_Page_Down, .page_down }, + .{ gdk.KEY_Escape, .escape }, + .{ gdk.KEY_Return, .enter }, + .{ gdk.KEY_Tab, .tab }, + .{ gdk.KEY_BackSpace, .backspace }, + .{ gdk.KEY_Print, .print_screen }, + .{ gdk.KEY_Pause, .pause }, + + .{ gdk.KEY_F1, .f1 }, + .{ gdk.KEY_F2, .f2 }, + .{ gdk.KEY_F3, .f3 }, + .{ gdk.KEY_F4, .f4 }, + .{ gdk.KEY_F5, .f5 }, + .{ gdk.KEY_F6, .f6 }, + .{ gdk.KEY_F7, .f7 }, + .{ gdk.KEY_F8, .f8 }, + .{ gdk.KEY_F9, .f9 }, + .{ gdk.KEY_F10, .f10 }, + .{ gdk.KEY_F11, .f11 }, + .{ gdk.KEY_F12, .f12 }, + .{ gdk.KEY_F13, .f13 }, + .{ gdk.KEY_F14, .f14 }, + .{ gdk.KEY_F15, .f15 }, + .{ gdk.KEY_F16, .f16 }, + .{ gdk.KEY_F17, .f17 }, + .{ gdk.KEY_F18, .f18 }, + .{ gdk.KEY_F19, .f19 }, + .{ gdk.KEY_F20, .f20 }, + .{ gdk.KEY_F21, .f21 }, + .{ gdk.KEY_F22, .f22 }, + .{ gdk.KEY_F23, .f23 }, + .{ gdk.KEY_F24, .f24 }, + .{ gdk.KEY_F25, .f25 }, + + .{ gdk.KEY_KP_0, .numpad_0 }, + .{ gdk.KEY_KP_1, .numpad_1 }, + .{ gdk.KEY_KP_2, .numpad_2 }, + .{ gdk.KEY_KP_3, .numpad_3 }, + .{ gdk.KEY_KP_4, .numpad_4 }, + .{ gdk.KEY_KP_5, .numpad_5 }, + .{ gdk.KEY_KP_6, .numpad_6 }, + .{ gdk.KEY_KP_7, .numpad_7 }, + .{ gdk.KEY_KP_8, .numpad_8 }, + .{ gdk.KEY_KP_9, .numpad_9 }, + .{ gdk.KEY_KP_Decimal, .numpad_decimal }, + .{ gdk.KEY_KP_Divide, .numpad_divide }, + .{ gdk.KEY_KP_Multiply, .numpad_multiply }, + .{ gdk.KEY_KP_Subtract, .numpad_subtract }, + .{ gdk.KEY_KP_Add, .numpad_add }, + .{ gdk.KEY_KP_Enter, .numpad_enter }, + .{ gdk.KEY_KP_Equal, .numpad_equal }, + + .{ gdk.KEY_KP_Separator, .numpad_separator }, + .{ gdk.KEY_KP_Left, .numpad_left }, + .{ gdk.KEY_KP_Right, .numpad_right }, + .{ gdk.KEY_KP_Up, .numpad_up }, + .{ gdk.KEY_KP_Down, .numpad_down }, + .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, + .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, + .{ gdk.KEY_KP_Home, .numpad_home }, + .{ gdk.KEY_KP_End, .numpad_end }, + .{ gdk.KEY_KP_Insert, .numpad_insert }, + .{ gdk.KEY_KP_Delete, .numpad_delete }, + .{ gdk.KEY_KP_Begin, .numpad_begin }, + + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + + .{ gdk.KEY_Shift_L, .shift_left }, + .{ gdk.KEY_Control_L, .control_left }, + .{ gdk.KEY_Alt_L, .alt_left }, + .{ gdk.KEY_Super_L, .meta_left }, + .{ gdk.KEY_Shift_R, .shift_right }, + .{ gdk.KEY_Control_R, .control_right }, + .{ gdk.KEY_Alt_R, .alt_right }, + .{ gdk.KEY_Super_R, .meta_right }, + + // TODO: media keys +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/media.zig b/vendor/libghostty-vt/src/apprt/gtk/media.zig new file mode 100644 index 00000000..1015c933 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/media.zig @@ -0,0 +1,102 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; + +const log = std.log.scoped(.gtk_media); + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +pub fn fromFilename(path: [:0]const u8) ?*gtk.MediaFile { + assert(std.fs.path.isAbsolute(path)); + std.fs.accessAbsolute(path, .{ .mode = .read_only }) catch |err| { + log.warn("unable to access {s}: {t}", .{ path, err }); + return null; + }; + return gtk.MediaFile.newForFilename(path); +} + +pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile { + assert(std.fs.path.isAbsolute(path)); + var gerr: ?*glib.Error = null; + + const found = gio.resourcesGetInfo(path, .{}, null, null, &gerr); + if (gerr) |err| { + defer err.free(); + log.warn( + "failed to find resource {s}: {s} {d} {s}", + .{ + path, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "(no message)", + }, + ); + return null; + } + + if (found == 0) { + log.warn("failed to find resource {s}", .{path}); + return null; + } + + return gtk.MediaFile.newForResource(path); +} + +pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void { + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileError, + null, + .{ .detail = "error" }, + ); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves. + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); +} + +fn mediaFileError( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, +) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + log.warn("error playing sound from {s}: {s} {d} {s}", .{ + path orelse "<<unknown>>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); +} + +fn mediaFileEnded( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, +) callconv(.c) void { + media_file.unref(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal.zig b/vendor/libghostty-vt/src/apprt/gtk/portal.zig new file mode 100644 index 00000000..3e51042d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/portal.zig @@ -0,0 +1,105 @@ +const std = @import("std"); + +const gio = @import("gio"); + +const Allocator = std.mem.Allocator; + +pub const OpenURI = @import("portal/OpenURI.zig"); +pub const token_hex_len = @sizeOf(usize) * 2; +pub const TokenBuffer = [token_hex_len + 1]u8; +const token_format = std.fmt.comptimePrint("{{x:0>{}}}", .{token_hex_len}); + +/// Generate a token suitable for use in requests to the XDG Desktop Portal +pub fn generateToken() usize { + return std.crypto.random.int(usize); +} + +/// Format a request token consistently for use in portal object paths and payloads. +pub fn formatToken(buf: *TokenBuffer, token: usize) [:0]const u8 { + return std.fmt.bufPrintZ(buf, token_format, .{token}) catch unreachable; +} + +/// Get the XDG portal request path for the current Ghostty instance. +/// +/// See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html +/// for the protocol of the Request interface. +pub fn getRequestPath(alloc: Allocator, dbus: *gio.DBusConnection, token: usize) (Allocator.Error || error{NoDBusUniqueName})![:0]const u8 { + // Get the unique name from D-Bus and strip the leading `:` + const unique_name = std.mem.span( + dbus.getUniqueName() orelse { + return error.NoDBusUniqueName; + }, + )[1..]; + + return buildRequestPath(alloc, unique_name, token); +} + +/// Build the XDG portal request path for given unique name and token. +fn buildRequestPath(alloc: Allocator, unique_name: []const u8, token: usize) Allocator.Error![:0]const u8 { + var token_buf: TokenBuffer = undefined; + const token_string = formatToken(&token_buf, token); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name, + token_string, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. In effect, this + // will turn a unique name like `1.192` into `1_192`. + // This sounds arbitrary, but it's part of the Request protocol. + _ = std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; +} + +/// Try and parse the token out of a request path. +pub fn parseRequestPathToken(request_path: []const u8) ?usize { + const index = std.mem.lastIndexOfScalar(u8, request_path, '/') orelse return null; + const token = request_path[index + 1 ..]; + return std.fmt.parseUnsigned(usize, token, 16) catch return null; +} + +test "formatToken pads to fixed width" { + const testing = std.testing; + + var token_buf: TokenBuffer = undefined; + const token = formatToken(&token_buf, 0x42); + + try testing.expectEqual(@as(usize, token_hex_len), token.len); + try testing.expectEqualStrings("0000000000000042", token); +} + +test "buildRequestPath" { + const testing = std.testing; + + const path = try buildRequestPath(testing.allocator, "1.42", 0x75af01a79c6fea34); + try testing.expectEqualStrings( + "/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34", + path, + ); + testing.allocator.free(path); +} + +test "buildRequestPath pads token" { + const testing = std.testing; + const path = try buildRequestPath(testing.allocator, "1.42", 0x42); + + try testing.expectEqualStrings( + "/org/freedesktop/portal/desktop/request/1_42/0000000000000042", + path, + ); + testing.allocator.free(path); +} + +test "parseRequestPathToken" { + const testing = std.testing; + + try testing.expectEqual(0x75af01a79c6fea34, parseRequestPathToken("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fea34").?); + try testing.expectEqual(null, parseRequestPathToken("/org/freedesktop/portal/desktop/request/1_42/75af01a79c6fGa34")); + try testing.expectEqual(null, parseRequestPathToken("75af01a79c6fea34")); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig new file mode 100644 index 00000000..97aa013e --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig @@ -0,0 +1,565 @@ +//! Use DBus to call the XDG Desktop Portal to open an URI. +//! See: https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html#org-freedesktop-portal-openuri-openuri +const OpenURI = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const App = @import("../App.zig"); +const portal = @import("../portal.zig"); +const apprt = @import("../../../apprt.zig"); + +const log = std.log.scoped(.openuri); + +/// The GTK app that we "belong" to. +app: *App, + +/// Connection to the D-Bus session bus that we'll use for all of our messaging. +dbus: ?*gio.DBusConnection = null, + +/// Mutex to protect modification of the entries map or the cleanup timer. +mutex: std.Thread.Mutex = .{}, + +/// Map to store data about any in-flight calls to the portal. +entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty, + +/// Used to manage a timer to clean up any orphan entries in the map. +cleanup_timer: ?c_uint = null, + +/// Set to false during shutdown so callbacks stop touching internal state. +alive: bool = true, + +const RequestData = struct { + open_uri: *OpenURI, + token: usize, + kind: apprt.action.OpenUrl.Kind, + uri: [:0]const u8, + request_path: [:0]const u8, + + pub fn init( + alloc: Allocator, + open_uri: *OpenURI, + token: usize, + kind: apprt.action.OpenUrl.Kind, + uri: []const u8, + request_path: []const u8, + ) Allocator.Error!*RequestData { + const uri_copy = try alloc.dupeZ(u8, uri); + errdefer alloc.free(uri_copy); + + const request_path_copy = try alloc.dupeZ(u8, request_path); + errdefer alloc.free(request_path_copy); + + const data = try alloc.create(RequestData); + errdefer alloc.destroy(data); + + data.* = .{ + .open_uri = open_uri, + .token = token, + .kind = kind, + .uri = uri_copy, + .request_path = request_path_copy, + }; + + return data; + } + + pub fn deinit(self: *const RequestData, alloc: Allocator) void { + alloc.free(self.uri); + alloc.free(self.request_path); + } +}; + +/// Data about any in-flight calls to the portal. +pub const Entry = struct { + /// When the request started. + start: std.time.Instant, + /// A token used by the portal to identify requests and responses. The + /// actual format of the token does not really matter as long as it can be + /// used as part of a D-Bus object path. `usize` was chosen since it's easy + /// to hash and to generate random tokens. + token: usize, + /// The "kind" of URI. Unused here, but we may need to pass it on to the + /// fallback URL opener if the D-Bus method fails. + kind: apprt.action.OpenUrl.Kind, + /// A copy of the URI that we are opening. We need our own copy since the + /// method calls are asynchronous and the original may have been freed by + /// the time we need it. + uri: [:0]const u8, + /// Used to manage a subscription to a D-Bus signal, which is how the XDG + /// Portal reports results of the method call. + subscription: ?c_uint = null, + + pub fn deinit(self: *const Entry, alloc: Allocator) void { + alloc.free(self.uri); + } +}; + +pub const Errors = error{ + /// Could not get a D-Bus connection + DBusConnectionRequired, + /// The D-Bus connection did not have a unique name. This _should_ be + /// impossible, but is handled for safety's sake. + NoDBusUniqueName, + /// The system was unable to give us the time. + TimerUnavailable, +}; + +pub fn init(app: *App) OpenURI { + return .{ + .app = app, + }; +} + +pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void { + self.dbus = dbus; +} + +pub fn deinit(self: *OpenURI) void { + const alloc = self.app.app.allocator(); + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + self.alive = false; + + self.stopCleanupTimer(); + + for (self.entries.entries.items(.value)) |entry| { + self.unsubscribeFromResponse(entry); + destroyEntry(alloc, entry); + } + + self.entries.deinit(alloc); + self.entries = .empty; + self.dbus = null; +} + +/// Send the D-Bus method call to the XDG Desktop portal. The result of the +/// method call will be reported asynchronously. +pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Errors)!void { + const alloc = self.app.app.allocator(); + const dbus = self.dbus orelse return error.DBusConnectionRequired; + + const token = portal.generateToken(); + const request_path = try portal.getRequestPath(alloc, dbus, token); + defer alloc.free(request_path); + + const request = try RequestData.init(alloc, self, token, value.kind, value.url, request_path); + errdefer { + request.deinit(alloc); + alloc.destroy(request); + } + + self.mutex.lock(); + defer self.mutex.unlock(); + + // Create an entry that is used to track the results of the D-Bus method + // call. + const entry = entry: { + const entry = try alloc.create(Entry); + errdefer alloc.destroy(entry); + entry.* = .{ + .start = std.time.Instant.now() catch return error.TimerUnavailable, + .token = token, + .kind = value.kind, + .uri = try alloc.dupeZ(u8, value.url), + }; + errdefer entry.deinit(alloc); + try self.entries.putNoClobber(alloc, token, entry); + break :entry entry; + }; + + errdefer { + _ = self.entries.swapRemove(token); + destroyEntry(alloc, entry); + } + + self.startCleanupTimer(); + self.subscribeToResponse(entry, dbus, request_path.ptr); + self.sendRequest(entry, dbus, request); +} + +/// Subscribe to the D-Bus signal that will contain the results of our method +/// call to the portal. This must be called with the mutex locked. +fn subscribeToResponse( + self: *OpenURI, + entry: *Entry, + dbus: *gio.DBusConnection, + request_path: [*:0]const u8, +) void { + assert(!self.mutex.tryLock()); + + if (entry.subscription != null) return; + + entry.subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + responseReceived, + self, + null, + ); +} + +/// Unsubscribe to the D-Bus signal that contains the result of the method call. +/// This will prevent a response from being processed multiple times. This must +/// be called when the mutex is locked. +fn unsubscribeFromResponse(self: *OpenURI, entry: *Entry) void { + assert(!self.mutex.tryLock()); + + // Unsubscribe from the response signal + if (entry.subscription) |subscription| { + const dbus = self.dbus orelse { + entry.subscription = null; + log.warn("unable to unsubscribe open uri response without dbus connection", .{}); + return; + }; + dbus.signalUnsubscribe(subscription); + entry.subscription = null; + } +} + +fn destroyEntry(alloc: Allocator, entry: *Entry) void { + entry.deinit(alloc); + alloc.destroy(entry); +} + +fn failRequest(self: *OpenURI, token: usize) ?*Entry { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return null; + + const entry = (self.entries.fetchSwapRemove(token) orelse return null).value; + self.unsubscribeFromResponse(entry); + return entry; +} + +fn failRequestAndFallback(self: *OpenURI, request: *const RequestData) void { + const alloc = self.app.app.allocator(); + const entry = self.failRequest(request.token) orelse return; + defer destroyEntry(alloc, entry); + + self.app.app.openUrlFallback(request.kind, request.uri); +} + +/// Send the D-Bus method call to the portal. The mutex must be locked when this +/// is called. +fn sendRequest( + self: *OpenURI, + entry: *Entry, + dbus: *gio.DBusConnection, + request: *RequestData, +) void { + assert(!self.mutex.tryLock()); + + const payload = payload: { + const builder_type = glib.VariantType.new("(ssa{sv})"); + defer builder_type.free(); + + // Initialize our builder to build up our parameters + var builder: glib.VariantBuilder = undefined; + builder.init(builder_type); + + // parent window - empty string means we have no window + builder.add("s", ""); + + // URI to open + builder.add("s", entry.uri.ptr); + + // Options + { + const options = glib.VariantType.new("a{sv}"); + defer options.free(); + + builder.open(options); + defer builder.close(); + + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "handle_token"); + + var token_buf: portal.TokenBuffer = undefined; + const token = portal.formatToken(&token_buf, entry.token); + + const handle_token = glib.Variant.newString(token.ptr); + builder.add("v", handle_token); + } + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "writable"); + + const writable = glib.Variant.newBoolean(@intFromBool(false)); + builder.add("v", writable); + } + { + const option = glib.VariantType.new("{sv}"); + defer option.free(); + + builder.open(option); + defer builder.close(); + + builder.add("s", "ask"); + + const ask = glib.Variant.newBoolean(@intFromBool(false)); + builder.add("v", ask); + } + } + + break :payload builder.end(); + }; + + // We're expecting an object path back from the method call. + const reply_type = glib.VariantType.new("(o)"); + defer reply_type.free(); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.OpenURI", + "OpenURI", + payload, + reply_type, + .{}, + -1, + null, + requestCallback, + request, + ); +} + +/// Process the result of the original method call. Receiving this result does +/// not indicate that the that the method call succeeded but it may contain an +/// error message that is useful to log for debugging purposes. +fn requestCallback( + source: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, +) callconv(.c) void { + const request: *RequestData = @ptrCast(@alignCast(ud orelse return)); + const self = request.open_uri; + const alloc = self.app.app.allocator(); + defer { + request.deinit(alloc); + alloc.destroy(request); + } + + const dbus = gobject.ext.cast(gio.DBusConnection, source orelse { + log.err("Open URI request finished without a D-Bus source object", .{}); + self.failRequestAndFallback(request); + return; + }) orelse { + log.err("Open URI request finished with an unexpected source object", .{}); + self.failRequestAndFallback(request); + return; + }; + + var err_: ?*glib.Error = null; + defer if (err_) |err| err.free(); + + const reply_ = dbus.callFinish(result, &err_); + + if (err_) |err| { + log.err("Open URI request failed={s} ({})", .{ + err.f_message orelse "(unknown)", + err.f_code, + }); + self.failRequestAndFallback(request); + return; + } + + const reply = reply_ orelse { + log.err("D-Bus method call returned a null value!", .{}); + self.failRequestAndFallback(request); + return; + }; + defer reply.unref(); + + const reply_type = glib.VariantType.new("(o)"); + defer reply_type.free(); + + if (reply.isOfType(reply_type) == 0) { + log.warn("Reply from D-Bus method call does not contain an object path!", .{}); + self.failRequestAndFallback(request); + return; + } + + var object_path: [*:0]const u8 = undefined; + reply.get("(&o)", &object_path); + + const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse { + log.warn("Unable to parse token from the object path {s}", .{object_path}); + self.failRequestAndFallback(request); + return; + }; + + if (token != request.token) { + log.warn("Open URI request returned mismatched token expected={x} actual={x}", .{ + request.token, + token, + }); + self.failRequestAndFallback(request); + return; + } + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + + const entry = self.entries.get(token) orelse return; + if (std.mem.eql(u8, request.request_path, std.mem.span(object_path))) return; + + log.debug("updating open uri request path old={s} new={s}", .{ + request.request_path, + object_path, + }); + self.unsubscribeFromResponse(entry); + self.subscribeToResponse(entry, dbus, object_path); +} + +/// Handle the response signal from the portal. This should contain the actual +/// results of the method call (success or failure). +fn responseReceived( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + object_path: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, +) callconv(.c) void { + const self: *OpenURI = @ptrCast(@alignCast(ud orelse { + log.err("OpenURI response received with null userdata", .{}); + return; + })); + + const alloc = self.app.app.allocator(); + + const token = portal.parseRequestPathToken(std.mem.span(object_path)) orelse { + log.warn("invalid object path: {s}", .{std.mem.span(object_path)}); + return; + }; + + self.mutex.lock(); + defer self.mutex.unlock(); + + if (!self.alive) return; + + const entry = (self.entries.fetchSwapRemove(token) orelse { + log.warn("no entry for token {x}", .{token}); + return; + }).value; + + defer destroyEntry(alloc, entry); + + self.unsubscribeFromResponse(entry); + + var response: u32 = 0; + var results: ?*glib.Variant = null; + defer if (results) |variant| variant.unref(); + params.get("(u@a{sv})", &response, &results); + + switch (response) { + 0 => { + log.debug("open uri successful", .{}); + }, + 1 => { + log.debug("open uri request was cancelled by the user", .{}); + }, + 2 => { + log.warn("open uri request ended unexpectedly", .{}); + self.app.app.openUrlFallback(entry.kind, entry.uri); + }, + else => { + log.err("unrecognized response code={}", .{response}); + self.app.app.openUrlFallback(entry.kind, entry.uri); + }, + } +} + +/// Wait this number of seconds and then clean up any orphaned entries. +const cleanup_timeout = 30; + +/// If there is an active cleanup timer, cancel it. This must be called with the +/// mutex locked +fn stopCleanupTimer(self: *OpenURI) void { + assert(!self.mutex.tryLock()); + + if (self.cleanup_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove cleanup timer source={d}", .{timer}); + } + self.cleanup_timer = null; + } +} + +/// Start a timer to clean up any entries that have not received a timely +/// response. If there is already a timer it will be stopped and replaced with a +/// new one. This must be called with the mutex locked. +fn startCleanupTimer(self: *OpenURI) void { + assert(!self.mutex.tryLock()); + + self.stopCleanupTimer(); + self.cleanup_timer = glib.timeoutAddSeconds(cleanup_timeout + 1, cleanup, self); +} + +/// The cleanup timer is used to free up any entries that may have failed +/// to get a response in a timely manner. +fn cleanup(ud: ?*anyopaque) callconv(.c) c_int { + const self: *OpenURI = @ptrCast(@alignCast(ud orelse { + log.warn("cleanup called with null userdata", .{}); + return @intFromBool(glib.SOURCE_REMOVE); + })); + + const alloc = self.app.app.allocator(); + + self.mutex.lock(); + defer self.mutex.unlock(); + + self.cleanup_timer = null; + if (!self.alive) return @intFromBool(glib.SOURCE_REMOVE); + + const now = std.time.Instant.now() catch { + // `now()` should never fail, but if it does, don't crash, just return. + // This might cause a small memory leak in rare circumstances but it + // should get cleaned up the next time a URL is clicked. + return @intFromBool(glib.SOURCE_REMOVE); + }; + + loop: while (true) { + for (self.entries.entries.items(.value)) |entry| { + if (now.since(entry.start) > cleanup_timeout * std.time.ns_per_s) { + log.warn("open uri request timed out token={x}", .{entry.token}); + self.unsubscribeFromResponse(entry); + _ = self.entries.swapRemove(entry.token); + self.app.app.openUrlFallback(entry.kind, entry.uri); + destroyEntry(alloc, entry); + continue :loop; + } + } + break :loop; + } + + return @intFromBool(glib.SOURCE_REMOVE); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig new file mode 100644 index 00000000..ff021950 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig @@ -0,0 +1,121 @@ +const std = @import("std"); + +const gio = @import("gio"); +const glib = @import("glib"); + +const log = std.log.scoped(.gtk_post_fork); + +const configpkg = @import("../../config.zig"); +const internal_os = @import("../../os/main.zig"); +const Command = @import("../../Command.zig"); +const cgroup = @import("./cgroup.zig"); + +const Application = @import("class/application.zig").Application; + +pub const PostForkInfo = struct { + gtk_single_instance: configpkg.Config.GtkSingleInstance, + linux_cgroup: configpkg.Config.LinuxCgroup, + linux_cgroup_hard_fail: bool, + linux_cgroup_memory_limit: ?u64, + linux_cgroup_processes_limit: ?u64, + + pub fn init(cfg: *const configpkg.Config) PostForkInfo { + return .{ + .gtk_single_instance = cfg.@"gtk-single-instance", + .linux_cgroup = cfg.@"linux-cgroup", + .linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail", + .linux_cgroup_memory_limit = cfg.@"linux-cgroup-memory-limit", + .linux_cgroup_processes_limit = cfg.@"linux-cgroup-processes-limit", + }; + } +}; + +/// If we are configured to do so, tell `systemd` to move the new child PID into +/// a transient `systemd` scope with the configured resource limits. +/// +/// If we are configured to hard fail, log an error message and return an error +/// code if we don't detect the move in time. +pub fn postFork(cmd: *Command) Command.PostForkError!void { + switch (cmd.rt_post_fork_info.linux_cgroup) { + .always => {}, + .never => return, + .@"single-instance" => switch (cmd.rt_post_fork_info.gtk_single_instance) { + .true => {}, + .false => return, + .detect => { + log.err("gtk-single-instance is set to detect which should be impossible!", .{}); + return error.PostForkError; + }, + }, + } + + const pid: u32 = @intCast(cmd.pid orelse { + log.err("PID of child not known!", .{}); + return error.PostForkError; + }); + + var expected_cgroup_buf: [256]u8 = undefined; + const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid); + + log.debug("beginning transition to transient systemd scope {s}", .{expected_cgroup}); + + const app = Application.default(); + + const dbus = app.as(gio.Application).getDbusConnection() orelse { + if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) { + log.err("dbus connection required for cgroup isolation, exiting", .{}); + return error.PostForkError; + } + return; + }; + + cgroup.createScope( + dbus, + pid, + .{ + .memory_high = cmd.rt_post_fork_info.linux_cgroup_memory_limit, + .tasks_max = cmd.rt_post_fork_info.linux_cgroup_processes_limit, + }, + ) catch |err| { + if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) { + log.err("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err }); + return error.PostForkError; + } + log.warn("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err }); + return; + }; + + const start = std.time.Instant.now() catch unreachable; + + loop: while (true) { + const now = std.time.Instant.now() catch unreachable; + + if (now.since(start) > 250 * std.time.ns_per_ms) { + if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) { + log.err("transition to new transient systemd scope {s} took too long", .{expected_cgroup}); + return error.PostForkError; + } + log.warn("transition to transient systemd scope {s} took too long", .{expected_cgroup}); + break :loop; + } + + not_found: { + var current_cgroup_buf: [4096]u8 = undefined; + + const current_cgroup_raw = internal_os.cgroup.current( + ¤t_cgroup_buf, + @intCast(pid), + ) orelse break :not_found; + + const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found; + const current_cgroup = current_cgroup_raw[index + 1 ..]; + + if (std.mem.eql(u8, current_cgroup, expected_cgroup)) { + log.debug("transition to transient systemd scope {s} complete", .{expected_cgroup}); + break :loop; + } + } + + std.Thread.sleep(25 * std.time.ns_per_ms); + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig new file mode 100644 index 00000000..6f6a9ed5 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig @@ -0,0 +1,81 @@ +const std = @import("std"); + +const log = std.log.scoped(.gtk_pre_exec); + +const configpkg = @import("../../config.zig"); + +const internal_os = @import("../../os/main.zig"); +const Command = @import("../../Command.zig"); +const cgroup = @import("./cgroup.zig"); + +pub const PreExecInfo = struct { + gtk_single_instance: configpkg.Config.GtkSingleInstance, + linux_cgroup: configpkg.Config.LinuxCgroup, + linux_cgroup_hard_fail: bool, + + pub fn init(cfg: *const configpkg.Config) PreExecInfo { + return .{ + .gtk_single_instance = cfg.@"gtk-single-instance", + .linux_cgroup = cfg.@"linux-cgroup", + .linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail", + }; + } +}; + +/// If we are expecting to be moved to a transient systemd scope, wait to see if +/// that happens by checking for the correct name of the current cgroup. Wait at +/// most 250ms so that we don't overly delay the soft-fail scenario. +/// +/// If we are configured to hard fail, log an error message and return an error +/// code if we don't detect the move in time. +pub fn preExec(cmd: *Command) ?u8 { + switch (cmd.rt_pre_exec_info.linux_cgroup) { + .always => {}, + .never => return null, + .@"single-instance" => switch (cmd.rt_pre_exec_info.gtk_single_instance) { + .true => {}, + .false => return null, + .detect => { + log.err("gtk-single-instance is set to detect", .{}); + return 127; + }, + }, + } + + const pid: u32 = @intCast(std.os.linux.getpid()); + + var expected_cgroup_buf: [256]u8 = undefined; + const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid); + + const start = std.time.Instant.now() catch unreachable; + + while (true) { + const now = std.time.Instant.now() catch unreachable; + + if (now.since(start) > 250 * std.time.ns_per_ms) { + if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) { + log.err("transition to new transient systemd scope took too long", .{}); + return 127; + } + break; + } + + not_found: { + var current_cgroup_buf: [4096]u8 = undefined; + + const current_cgroup_raw = internal_os.cgroup.current( + ¤t_cgroup_buf, + @intCast(pid), + ) orelse break :not_found; + + const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found; + const current_cgroup = current_cgroup_raw[index + 1 ..]; + + if (std.mem.eql(u8, current_cgroup, expected_cgroup)) return null; + } + + std.Thread.sleep(25 * std.time.ns_per_ms); + } + + return null; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp new file mode 100644 index 00000000..5f29981d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp @@ -0,0 +1,86 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + notify::blur => $notify_blur(); + notify::request => $notify_request(); + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [overlay] + Button reveal_button { + clicked => $reveal_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + clicked => $hide_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp new file mode 100644 index 00000000..f58ef523 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp @@ -0,0 +1,12 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyCloseConfirmationDialog: $GhosttyDialog { + responses [ + cancel: _("Cancel"), + close: _("Close") destructive, + ] + + close-response: "cancel"; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp new file mode 100644 index 00000000..845909eb --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp @@ -0,0 +1,28 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyConfigErrorsDialog: $GhosttyDialog { + heading: _("Configuration Errors"); + body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); + + responses [ + ignore: _("Ignore"), + reload: _("Reload Configuration") suggested, + ] + + extra-child: ScrolledWindow { + min-content-width: 500; + min-content-height: 100; + + TextView { + editable: false; + cursor-visible: false; + top-margin: 8; + bottom-margin: 8; + left-margin: 8; + right-margin: 8; + buffer: bind (template.config as <$GhosttyConfig>).diagnostics-buffer; + } + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/debug-warning.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/debug-warning.blp new file mode 100644 index 00000000..34411ce9 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/debug-warning.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; + +template $GhosttyDebugWarning: Gtk.Box { + orientation: vertical; + + Gtk.Label { + label: _("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); + margin-top: 10; + margin-bottom: 10; + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/key-state-overlay.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/key-state-overlay.blp new file mode 100644 index 00000000..c8654bfb --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/key-state-overlay.blp @@ -0,0 +1,58 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyKeyStateOverlay: Adw.Bin { + visible: bind $has_state(template.has-tables, template.has-sequence) as <bool>; + valign-target: end; + halign: center; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "key-state-overlay", + ] + + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "input-keyboard-symbolic"; + pixel-size: 16; + } + + Label tables_label { + visible: bind template.has-tables; + label: bind $tables_text(template.tables) as <string>; + xalign: 0.0; + } + + Label chevron_label { + visible: bind $show_chevron(template.has-tables, template.has-sequence) as <bool>; + label: "›"; + + styles [ + "dim-label", + ] + } + + Label sequence_label { + visible: bind template.has-sequence; + label: bind $sequence_text(template.sequence) as <string>; + xalign: 0.0; + } + + Spinner pending_spinner { + visible: bind template.has-sequence; + spinning: bind template.has-sequence; + } + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/resize-overlay.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/resize-overlay.blp new file mode 100644 index 00000000..a05986b4 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/resize-overlay.blp @@ -0,0 +1,33 @@ +using Gtk 4.0; +using Adw 1; +// We can't inherit directly from Label because its an opaque +// type in zig-gobject. +template $GhosttyResizeOverlay: Adw.Bin { + visible: false; + can-focus: false; + can-target: false; + focusable: false; + focus-on-click: false; + duration: 750; + first-delay: 250; + overlay-halign: center; + overlay-valign: center; + // See surface.blp for why we need to wrap this. + Adw.Bin { + Label label { + styles [ + "background", + "resize-overlay", + ] + + can-focus: false; + can-target: false; + focusable: false; + focus-on-click: false; + selectable: false; + justify: center; + halign: bind template.overlay-halign; + valign: bind template.overlay-valign; + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp new file mode 100644 index 00000000..6523d414 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -0,0 +1,94 @@ +using Gtk 4.0; +using Gdk 4.0; +using Adw 1; + +template $GhosttySearchOverlay: Adw.Bin { + visible: bind template.active; + halign-target: end; + valign-target: start; + halign: bind template.halign-target; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "search-overlay", + ] + + orientation: horizontal; + spacing: 6; + + SearchEntry search_entry { + placeholder-text: _("Find…"); + width-chars: 20; + hexpand: true; + stop-search => $stop_search(); + search-changed => $search_changed(); + next-match => $next_match(); + previous-match => $previous_match(); + + EventControllerKey { + // We need this so we capture before the SearchEntry. + propagation-phase: capture; + key-pressed => $search_entry_key_pressed(); + } + } + + Label { + styles [ + "dim-label", + ] + + label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as <string>; + width-chars: 6; + xalign: 1.0; + } + + Box button_box { + orientation: horizontal; + spacing: 1; + + styles [ + "linked", + ] + + Button prev_button { + icon-name: "go-up-symbolic"; + tooltip-text: _("Previous Match"); + clicked => $next_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + + Button next_button { + icon-name: "go-down-symbolic"; + tooltip-text: _("Next Match"); + clicked => $previous_match(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + + Button close_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Close"); + clicked => $stop_search(); + + cursor: Gdk.Cursor { + name: "pointer"; + }; + } + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp new file mode 100644 index 00000000..794ea180 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp @@ -0,0 +1,371 @@ +using Gtk 4.0; +using Adw 1; + +Adw.StatusPage error_page { + icon-name: "computer-fail-symbolic"; + title: _("Oh, no."); + description: _("Unable to acquire an OpenGL context for rendering."); + + child: LinkButton { + label: "https://ghostty.org/docs/help/gtk-opengl-context"; + uri: "https://ghostty.org/docs/help/gtk-opengl-context"; + }; +} + +Overlay terminal_page { + focusable: false; + focus-on-click: false; + + child: Box { + hexpand: true; + vexpand: true; + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + allowed-apis: gl; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerScroll { + scroll => $scroll_vertical(); + scroll-begin => $scroll_vertical_begin(); + scroll-end => $scroll_vertical_end(); + flags: vertical; + } + + EventControllerScroll { + scroll => $scroll_horizontal(); + flags: horizontal; + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } + }; + + [overlay] + Revealer { + reveal-child: bind template.readonly; + transition-type: crossfade; + transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; + + Box readonly_overlay { + styles [ + "readonly_overlay", + ] + + // TODO: the tooltip doesn't actually work, but keep it here for now so + // that we can get the tooltip text translated. + has-tooltip: true; + tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + halign: end; + valign: start; + spacing: 6; + + Image { + icon-name: "changes-prevent-symbolic"; + } + + Label { + label: _("Read-only"); + } + } + } + + [overlay] + ProgressBar progress_bar_overlay { + styles [ + "osd", + ] + + visible: false; + halign: fill; + valign: start; + } + + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>; + transition-type: crossfade; + transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + + [overlay] + $GhosttySurfaceChildExited child_exited_overlay { + visible: bind template.child-exited; + close-request => $child_exited_close(); + } + + [overlay] + $GhosttyResizeOverlay resize_overlay {} + + [overlay] + Label url_left { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: start; + valign: end; + label: bind template.mouse-hover-url; + + EventControllerMotion url_ec_motion { + enter => $url_mouse_enter(); + leave => $url_mouse_leave(); + } + } + + [overlay] + Label url_right { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: end; + valign: end; + label: bind template.mouse-hover-url; + } + + [overlay] + $GhosttySearchOverlay search_overlay { + stop-search => $search_stop(); + search-changed => $search_changed(); + next-match => $search_next_match(); + previous-match => $search_previous_match(); + } + + [overlay] + $GhosttyKeyStateOverlay key_state_overlay { + tables: bind template.key-table; + sequence: bind template.key-sequence; + } + + [overlay] + // Apply unfocused-split-fill and unfocused-split-opacity to current surface + // this is only applied when a tab has more than one surface + Revealer { + reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as <bool>; + transition-duration: 0; + // This is all necessary so that the Revealer itself doesn't override + // any input events from the other overlays. Namely, if you don't have + // these then the search overlay won't get mouse events. + can-focus: false; + can-target: false; + focusable: false; + + DrawingArea { + styles [ + "unfocused-split", + ] + } + } + + DropTarget drop_target { + drop => $drop(); + actions: copy | move; + } +} + +template $GhosttySurface: Adw.Bin { + styles [ + "surface", + ] + + notify::config => $notify_config(); + notify::error => $notify_error(); + notify::mouse-hover-url => $notify_mouse_hover_url(); + notify::mouse-hidden => $notify_mouse_hidden(); + notify::mouse-shape => $notify_mouse_shape(); + notify::vadjustment => $notify_vadjustment(); + // Some history: we used to use a Stack here and swap between the + // terminal and error pages as needed. But a Stack doesn't play nice + // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with + // a manual programmatic child swap fixed this. So if you ever change + // this, be sure to test many splits! + // + // [^1]: https://github.com/ghostty-org/ghostty/issues/8533 + child: terminal_page; +} + +IMMulticontext im_context { + input-purpose: terminal; + preedit-start => $im_preedit_start(); + preedit-changed => $im_preedit_changed(); + preedit-end => $im_preedit_end(); + commit => $im_commit(); +} + +menu context_menu_model { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + + item { + label: _("Notify on Next Command Finish"); + action: "surface.notify-on-next-command-finish"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "surface.prompt-title"; + } + + item { + label: _("Split Up"); + action: "split-tree.new-split"; + target: "up"; + } + + item { + label: _("Split Down"); + action: "split-tree.new-split"; + target: "down"; + } + + item { + label: _("Split Left"); + action: "split-tree.new-split"; + target: "left"; + } + + item { + label: _("Split Right"); + action: "split-tree.new-split"; + target: "right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("Change Tab Title…"); + action: "tab.prompt-tab-title"; + } + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/debug-warning.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/debug-warning.blp new file mode 100644 index 00000000..ad7246a8 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/debug-warning.blp @@ -0,0 +1,9 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyDebugWarning: Adw.Bin { + Adw.Banner { + title: _("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); + revealed: true; + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/surface-child-exited.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/surface-child-exited.blp new file mode 100644 index 00000000..6745f0ca --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.3/surface-child-exited.blp @@ -0,0 +1,22 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurfaceChildExited: Adw.Bin { + styles [ + "child-exited", + ] + + notify::data => $notify_data(); + + Adw.Bin { + Adw.Banner banner { + button-clicked => $clicked(); + revealed: true; + // Not localized on purpose because it should never be seen. + title: "This is a bug in Ghostty. Please report it."; + button-label: _("Close"); + halign: fill; + valign: end; + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp new file mode 100644 index 00000000..144e3a37 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp @@ -0,0 +1,96 @@ +using Gtk 4.0; +// This is unused but if we remove it we get a blueprint-compiler error. +using Adw 1; + +template $GhosttyClipboardConfirmationDialog: $GhosttyDialog { + styles [ + "clipboard-confirmation-dialog", + ] + + notify::blur => $notify_blur(); + notify::request => $notify_request(); + heading: _("Authorize Clipboard Access"); + // Not localized because this is a placeholder users never see. + body: "If you see this text, there is a bug in Ghostty. Please report it."; + + responses [ + cancel: _("Deny") suggested, + ok: _("Allow") destructive, + ] + + default-response: "cancel"; + close-response: "cancel"; + + extra-child: ListBox { + selection-mode: none; + + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] + + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + styles [ + "clipboard-contents", + ] + + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + buffer: bind template.clipboard-contents; + } + } + + [overlay] + Button reveal_button { + clicked => $reveal_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + clicked => $hide_clicked(); + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + styles [ + "card", + ] + + visible: bind template.can-remember; + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); + } + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/command-palette.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/command-palette.blp new file mode 100644 index 00000000..473fb1f0 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/command-palette.blp @@ -0,0 +1,110 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog dialog { + content-width: 700; + closed => $closed(); + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + Gtk.SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + stop-search => $search_stopped(); + activate => $search_activated(); + + styles [ + "command-palette-search", + ] + } + } + + Gtk.ScrolledWindow { + min-content-height: 300; + + Gtk.ListView view { + show-separators: true; + single-click-activate: true; + activate => $row_activated(); + + model: Gtk.SingleSelection model { + model: Gtk.FilterListModel { + incremental: true; + + filter: Gtk.AnyFilter { + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: Gtk.BuilderListItemFactory { + template Gtk.ListItem { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Gtk.Box { + orientation: vertical; + hexpand: true; + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + Gtk.ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/imgui-widget.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/imgui-widget.blp new file mode 100644 index 00000000..d5b973a7 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/imgui-widget.blp @@ -0,0 +1,51 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyImguiWidget: Adw.Bin { + styles [ + "imgui", + ] + + Adw.Bin { + Gtk.GLArea gl_area { + auto-render: true; + // needs to be focusable so that we can receive events + focusable: true; + focus-on-click: true; + allowed-apis: gl; + realize => $realize(); + unrealize => $unrealize(); + resize => $resize(); + render => $render(); + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + GestureClick { + pressed => $mouse_pressed(); + released => $mouse_released(); + button: 0; + } + + EventControllerMotion { + motion => $mouse_motion(); + } + + EventControllerScroll { + scroll => $scroll(); + flags: both_axes; + } + } + } +} + +IMMulticontext im_context { + commit => $im_commit(); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-widget.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-widget.blp new file mode 100644 index 00000000..ac15ab4e --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-widget.blp @@ -0,0 +1,10 @@ +using Gtk 4.0; + +template $GhosttyInspectorWidget: $GhosttyImguiWidget { + styles [ + "inspector", + ] + + hexpand: true; + vexpand: true; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-window.blp new file mode 100644 index 00000000..a7625bc2 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/inspector-window.blp @@ -0,0 +1,38 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWindow: Adw.ApplicationWindow { + title: _("Ghostty: Terminal Inspector"); + icon-name: "com.mitchellh.ghostty"; + default-width: 1000; + default-height: 600; + + styles [ + "inspector", + ] + + content: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: bind template.title; + }; + } + + Gtk.Box { + orientation: vertical; + spacing: 0; + hexpand: true; + vexpand: true; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + $GhosttyInspectorWidget inspector_widget { + notify::surface => $notify_inspector_surface(); + surface: bind template.surface; + } + } + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp new file mode 100644 index 00000000..182919f4 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree-split.blp @@ -0,0 +1,20 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTreeSplit: Adw.Bin { + styles [ + "split", + ] + + // The double-nesting is required due to a GTK bug where you can't + // bind the first child of a builder layout. If you do, you get a double + // dispose. Easiest way to see that is simply remove this and see the + // GTK critical errors (and sometimes crashes). + Adw.Bin { + Paned paned { + notify::max-position => $notify_max_position(); + notify::min-position => $notify_min_position(); + notify::position => $notify_position(); + } + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree.blp new file mode 100644 index 00000000..e8c53b60 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/split-tree.blp @@ -0,0 +1,25 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Box { + notify::tree => $notify_tree(); + orientation: vertical; + + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp new file mode 100644 index 00000000..75582a89 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; +using Adw 1; + +template $GhostttySurfaceScrolledWindow: Adw.Bin { + notify::surface => $notify_surface(); + + Gtk.ScrolledWindow scrolled_window { + hscrollbar-policy: never; + vscrollbar-policy: bind $scrollbar_policy(template.config) as <Gtk.PolicyType>; + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp new file mode 100644 index 00000000..55f2e7ef --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; + +template $GhosttyTab: Box { + styles [ + "tab", + ] + + orientation: vertical; + hexpand: true; + vexpand: true; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as <string>; + tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; + + $GhosttySplitTree split_tree { + notify::active-surface => $notify_active_surface(); + notify::tree => $notify_tree(); + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/title-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/title-dialog.blp new file mode 100644 index 00000000..737a92b5 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/title-dialog.blp @@ -0,0 +1,18 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyTitleDialog: Adw.AlertDialog { + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel"), + ok: _("OK") suggested, + ] + + default-response: "ok"; + focus-widget: entry; + + extra-child: Entry entry { + activates-default: true; + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp new file mode 100644 index 00000000..b66a9309 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp @@ -0,0 +1,323 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyWindow: Adw.ApplicationWindow { + styles [ + "window", + ] + + close-request => $close_request(); + realize => $realize(); + notify::config => $notify_config(); + notify::fullscreened => $notify_fullscreened(); + notify::is-active => $notify_is_active(); + notify::maximized => $notify_maximized(); + notify::quick-terminal => $notify_quick_terminal(); + notify::scale-factor => $notify_scale_factor(); + default-width: 800; + default-height: 600; + // GTK4 grabs F10 input by default to focus the menubar icon. We want + // to disable this so that terminal programs can capture F10 (such as htop) + handle-menubar-accel: false; + + content: Adw.TabOverview tab_overview { + create-tab => $overview_create_tab(); + notify::open => $overview_notify_open(); + view: tab_view; + enable-new-tab: true; + // Disable the title buttons (close, maximize, minimize, ...) + // *inside* the tab overview if CSDs are disabled. + // We do spare the search button, though. + show-start-title-buttons: bind template.decorated; + show-end-title-buttons: bind template.decorated; + + Adw.ToolbarView toolbar { + top-bar-style: bind template.toolbar-style; + bottom-bar-style: bind template.toolbar-style; + + [top] + Adw.HeaderBar { + visible: bind template.headerbar-visible; + + title-widget: Adw.WindowTitle { + title: bind template.title; + // Blueprint auto-formatter won't let me split this into multiple + // lines. Let me explain myself. All parameters to a closure are used + // as notifications to recompute the value of the closure. All + // elements of a property chain are also subscribed to for changes. + // This one long, ugly line saves us from manually building up this + // massive notify chain in code. + subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as <string>; + }; + + [start] + Adw.SplitButton { + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + + [end] + Gtk.Box { + Gtk.ToggleButton { + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + } + } + + [top] + Adw.TabBar tab_bar { + autohide: bind template.tabs-autohide; + expand-tabs: bind template.tabs-wide; + view: tab_view; + visible: bind template.tabs-visible; + + [start] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as <bool>; + + Gtk.WindowControls { + side: start; + } + + Adw.SplitButton { + styles [ + "flat", + ] + + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + } + + [end] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as <bool>; + + Gtk.ToggleButton { + styles [ + "flat", + ] + + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + styles [ + "flat", + ] + + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + + Gtk.WindowControls { + side: end; + } + } + } + + Box { + orientation: vertical; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + Adw.ToastOverlay toast_overlay { + Adw.TabView tab_view { + notify::n-pages => $notify_n_pages(); + notify::selected-page => $notify_selected_page(); + close-page => $close_page(); + page-attached => $page_attached(); + page-detached => $page_detached(); + create-window => $tab_create_window(); + setup-menu => $setup_tab_menu(); + menu-model: tab_context_menu; + shortcuts: none; + } + } + } + } + }; +} + +menu split_menu { + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } +} + +menu main_menu { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + + section { + item { + label: _("Change Tab Title…"); + action: "win.prompt-tab-title"; + } + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + target: "this"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-surface-title"; + } + + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + item { + label: _("Command Palette"); + action: "win.toggle-command-palette"; + } + + item { + label: _("Terminal Inspector"); + action: "win.toggle-inspector"; + } + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + + section { + item { + label: _("About Ghostty"); + action: "win.about"; + } + + item { + label: _("Quit"); + action: "app.quit"; + } + } +} + +menu tab_context_menu { + item { + label: _("Change Tab Title…"); + action: "win.prompt-context-tab-title"; + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig new file mode 100644 index 00000000..f689e45f --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig @@ -0,0 +1,44 @@ +const std = @import("std"); +const gtk = @import("gtk"); +const gobject = @import("gobject"); + +/// A lightweight wrapper around gobject.WeakRef to make it type-safe +/// to hold a single type of value. +pub fn WeakRef(comptime T: type) type { + return struct { + const Self = @This(); + + ref: gobject.WeakRef = std.mem.zeroes(gobject.WeakRef), + + pub const empty: Self = .{}; + + /// Set the weak reference to the given object. This will not + /// increase the reference count of the object. + pub fn set(self: *Self, v_: ?*T) void { + if (v_) |v| { + self.ref.set(v.as(gobject.Object)); + } else { + self.ref.set(null); + } + } + + /// Get a strong reference to the object, or null if the object + /// has been finalized. This increases the reference count by one. + pub fn get(self: *Self) ?*T { + // We can't use `as` because `as` guarantees conversion and + // that can't be statically guaranteed. + return gobject.ext.cast(T, self.ref.get() orelse return null); + } + }; +} + +test WeakRef { + const testing = std.testing; + + var ref: WeakRef(gtk.TextBuffer) = .empty; + const obj: *gtk.TextBuffer = .new(null); + ref.set(obj); + ref.get().?.unref(); // The "?" asserts non-null + obj.unref(); + try testing.expect(ref.get() == null); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig new file mode 100644 index 00000000..d409d6c2 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig @@ -0,0 +1,155 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); + +const Config = @import("../../config.zig").Config; +const input = @import("../../input.zig"); +const key = @import("key.zig"); +const ApprtWindow = @import("class/window.zig").Window; + +pub const noop = @import("winproto/noop.zig"); +pub const x11 = @import("winproto/x11.zig"); +pub const wayland = @import("winproto/wayland.zig"); + +pub const Protocol = enum { + none, + wayland, + x11, +}; + +/// App-state for the underlying windowing protocol. There should be one +/// instance of this struct per application. +pub const App = union(Protocol) { + none: noop.App, + wayland: if (build_options.wayland) wayland.App else noop.App, + x11: if (build_options.x11) x11.App else noop.App, + + pub fn init( + alloc: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !App { + inline for (@typeInfo(App).@"union".fields) |field| { + if (try field.type.init( + alloc, + gdk_display, + app_id, + config, + )) |v| { + return @unionInit(App, field.name, v); + } + } + + return .{ .none = .{} }; + } + + pub fn deinit(self: *App) void { + switch (self.*) { + inline else => |*v| v.deinit(), + } + } + + pub fn eventMods( + self: *App, + device: ?*gdk.Device, + gtk_mods: gdk.ModifierType, + ) input.Mods { + return switch (self.*) { + inline else => |*v| v.eventMods(device, gtk_mods), + } orelse key.translateMods(gtk_mods); + } + + pub fn supportsQuickTerminal(self: App) bool { + return switch (self) { + inline else => |v| v.supportsQuickTerminal(), + }; + } + + /// Set up necessary support for the quick terminal that must occur + /// *before* the window-level winproto object is created. + /// + /// Only has an effect on the Wayland backend, where the gtk4-layer-shell + /// library is initialized. + pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { + switch (self.*) { + inline else => |*v| try v.initQuickTerminal(apprt_window), + } + } +}; + +/// Per-Window state for the underlying windowing protocol. +/// +/// In Wayland, the terminology used is "Surface" and for it, this is +/// really "Surface"-specific state. But Ghostty uses the term "Surface" +/// heavily to mean something completely different, so we use "Window" here +/// to better match what it generally maps to in the Ghostty codebase. +pub const Window = union(Protocol) { + none: noop.Window, + wayland: if (build_options.wayland) wayland.Window else noop.Window, + x11: if (build_options.x11) x11.Window else noop.Window, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + return switch (app.*) { + inline else => |*v, tag| { + inline for (@typeInfo(Window).@"union".fields) |field| { + if (comptime std.mem.eql( + u8, + field.name, + @tagName(tag), + )) return @unionInit( + Window, + field.name, + try field.type.init( + alloc, + v, + apprt_window, + ), + ); + } + }, + }; + } + + pub fn deinit(self: *Window) void { + switch (self.*) { + inline else => |*v| v.deinit(), + } + } + + pub fn resizeEvent(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.resizeEvent(), + } + } + + pub fn syncAppearance(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.syncAppearance(), + } + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self) { + inline else => |v| v.clientSideDecorationEnabled(), + }; + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + switch (self.*) { + inline else => |*v| try v.addSubprocessEnv(env), + } + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + switch (self.*) { + inline else => |*v| try v.setUrgent(urgent), + } + } +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/BlurRegion.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/BlurRegion.zig new file mode 100644 index 00000000..f3041dae --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/BlurRegion.zig @@ -0,0 +1,187 @@ +const BlurRegion = @This(); +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const Window = @import("../winproto.zig").Window; +const ApprtWindow = @import("../class/window.zig").Window; + +slices: std.ArrayList(Slice), + +/// A rectangular slice of the blur region. +// Marked `extern` since we want to be able to use this in X11 directly. +pub const Slice = extern struct { + x: Pos, + y: Pos, + width: Pos, + height: Pos, +}; + +// X11 compatibility. Ideally this should just be an `i32` like Wayland, +// but XLib sucks +const Pos = c_long; + +pub const empty: BlurRegion = .{ + .slices = .empty, +}; + +pub fn deinit(self: *BlurRegion, alloc: Allocator) void { + self.slices.deinit(alloc); + self.slices = .empty; +} + +// Calculate the blur regions for a window. +// +// Since we have rounded corners by default, we need to carve out the +// pixels on each corner to avoid the "korners bug". +// (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) +pub fn calcForWindow( + alloc: Allocator, + window: *ApprtWindow, + csd: bool, + to_device_coordinates: bool, +) Allocator.Error!BlurRegion { + const native = window.as(gtk.Native); + const surface = native.getSurface() orelse return .empty; + + var slices: std.ArrayList(Slice) = .empty; + errdefer slices.deinit(alloc); + + // Calculate the primary blur region + // (the one that covers most of the screen). + // It's easier to do this inside a vector since we have to scale + // everything by the scale factor anyways. + + // NOTE(pluiedev): CSDs are a f--king mistake. + // Please, GNOME, stop this nonsense of making a window ~30% bigger + // internally than how they really are just for your shadows and + // rounded corners and all that fluff. Please. I beg of you. + const x: Pos, const y: Pos = off: { + var x: f64 = 0; + var y: f64 = 0; + native.getSurfaceTransform(&x, &y); + // Slightly inset the corners if we're using CSDs + if (csd) { + x += 1; + y += 1; + } + break :off .{ @intFromFloat(x), @intFromFloat(y) }; + }; + + var width = @as(Pos, surface.getWidth()); + var height = @as(Pos, surface.getHeight()); + + // Trim off the offsets. Be careful not to get negative. + width -= x * 2; + height -= y * 2; + if (width <= 0 or height <= 0) return .empty; + + // Empirically determined. + const are_corners_rounded = rounded: { + // This cast should always succeed as all of our windows + // should be toplevel. If this fails, something very strange + // is going on. + const toplevel = gobject.ext.cast( + gdk.Toplevel, + surface, + ) orelse break :rounded false; + + const state = toplevel.getState(); + if (state.fullscreen or state.maximized or state.tiled) + break :rounded false; + + break :rounded csd; + }; + + const new_slices = try approxRoundedRect( + alloc, + x, + y, + width, + height, + // See https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/css-variables.html#window-radius + if (are_corners_rounded) 15 else 0, + ); + + if (to_device_coordinates) { + // Transform surface coordinates to device coordinates. + const sf = surface.getScaleFactor(); + for (new_slices.items) |*s| { + s.x *= sf; + s.y *= sf; + s.width *= sf; + s.height *= sf; + } + } + + return .{ .slices = new_slices }; +} + +/// Whether two sets of blur regions are equal. +pub fn eql(self: BlurRegion, other: BlurRegion) bool { + if (self.slices.items.len != other.slices.items.len) return false; + for (self.slices.items, other.slices.items) |this, that| { + if (!std.meta.eql(this, that)) return false; + } + return true; +} + +/// Approximate a rounded rectangle with many smaller rectangles. +fn approxRoundedRect( + alloc: Allocator, + x: Pos, + y: Pos, + width: Pos, + height: Pos, + radius: Pos, +) Allocator.Error!std.ArrayList(Slice) { + const r_f: f32 = @floatFromInt(radius); + + var slices: std.ArrayList(Slice) = .empty; + errdefer slices.deinit(alloc); + + // Add the central rectangle + try slices.append(alloc, .{ + .x = x, + .y = y + radius, + .width = width, + .height = height - 2 * radius, + }); + + // Add the corner rows. This is honestly quite cursed. + var row: Pos = 0; + while (row < radius) : (row += 1) { + // y distance from this row to the center corner circle + const dy = @as(f32, @floatFromInt(radius - row)) - 0.5; + + // x distance - as given by the definition of a circle + const dx = @sqrt(r_f * r_f - dy * dy); + + // How much each row should be offset, rounded to an integer + const row_x: Pos = @intFromFloat(r_f - @round(dx + 0.5)); + + // Remove the offset from both ends + const row_w = width - 2 * row_x; + + // Top slice + try slices.append(alloc, .{ + .x = x + row_x, + .y = y + row, + .width = row_w, + .height = 1, + }); + + // Bottom slice + try slices.append(alloc, .{ + .x = x + row_x, + .y = y + height - 1 - row, + .width = row_w, + .height = 1, + }); + } + + return slices; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig new file mode 100644 index 00000000..950ee0f3 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); + +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); +const ApprtWindow = @import("../class/window.zig").Window; + +const log = std.log.scoped(.winproto_noop); + +pub const App = struct { + pub fn init( + _: Allocator, + _: *gdk.Display, + _: [:0]const u8, + _: *const Config, + ) !?App { + return null; + } + + pub fn deinit(self: *App) void { + _ = self; + } + + pub fn eventMods( + _: *App, + _: ?*gdk.Device, + _: gdk.ModifierType, + ) ?input.Mods { + return null; + } + + pub fn supportsQuickTerminal(_: App) bool { + return false; + } + pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} +}; + +pub const Window = struct { + pub fn init( + _: Allocator, + _: *App, + _: *ApprtWindow, + ) !Window { + return .{}; + } + + pub fn deinit(self: *Window) void { + _ = self; + } + + pub fn updateConfigEvent( + _: *Window, + _: *const ApprtWindow.DerivedConfig, + ) !void {} + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(_: *Window) !void {} + + /// This returns true if CSD is enabled for this window. This + /// should be the actual present state of the window, not the + /// desired state. + pub fn clientSideDecorationEnabled(self: Window) bool { + _ = self; + return true; + } + + pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + + pub fn setUrgent(_: *Window, _: bool) !void {} +}; diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig new file mode 100644 index 00000000..12c7fb8a --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig @@ -0,0 +1,486 @@ +//! Wayland protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); +const gdk_wayland = @import("gdk_wayland"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const layer_shell = @import("gtk4-layer-shell"); + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const ext = wayland.client.ext; +const kde = wayland.client.kde; +const org = wayland.client.org; +const xdg = wayland.client.xdg; + +const Config = @import("../../../config.zig").Config; +const Globals = @import("wayland/Globals.zig"); +const input = @import("../../../input.zig"); +const ApprtWindow = @import("../class/window.zig").Window; +const BlurRegion = @import("BlurRegion.zig"); + +const log = std.log.scoped(.winproto_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const App = struct { + display: *wl.Display, + globals: *Globals, + + pub fn init( + alloc: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = config; + _ = app_id; + + const gdk_wayland_display = gobject.ext.cast( + gdk_wayland.WaylandDisplay, + gdk_display, + ) orelse return null; + + const display: *wl.Display = @ptrCast(@alignCast( + gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, + )); + + const globals: *Globals = try .init(alloc, display); + errdefer globals.deinit(); + + return .{ + .display = display, + .globals = globals, + }; + } + + pub fn deinit(self: *App) void { + self.globals.deinit(); + } + + pub fn eventMods( + _: *App, + _: ?*gdk.Device, + _: gdk.ModifierType, + ) ?input.Mods { + return null; + } + + pub fn supportsQuickTerminal(self: App) bool { + _ = self; + if (!layer_shell.isSupported()) { + log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); + return false; + } + + return true; + } + + pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { + const window = apprt_window.as(gtk.Window); + layer_shell.initForWindow(window); + + // Set target monitor based on config (null lets compositor decide) + const monitor = resolveQuickTerminalMonitor(self.globals, apprt_window); + defer if (monitor) |v| v.unref(); + layer_shell.setMonitor(window, monitor); + } +}; + +/// Per-window (wl_surface) state for the Wayland protocol. +pub const Window = struct { + apprt_window: *ApprtWindow, + + /// The Wayland surface for this window. + surface: *wl.Surface, + + /// The context from the app where we can load our Wayland interfaces. + globals: *Globals, + + /// Object that controls background effects like background blur. + bg_effect: ?*ext.BackgroundEffectSurfaceV1 = null, + + /// Object that controls the decoration mode (client/server/auto) + /// of the window. + decoration: ?*org.KdeKwinServerDecoration = null, + + /// Object that controls the slide-in/slide-out animations of the + /// quick terminal. Always null for windows other than the quick terminal. + slide: ?*org.KdeKwinSlide = null, + + /// Object that, when present, denotes that the window is currently + /// requesting attention from the user. + activation_token: ?*xdg.ActivationTokenV1 = null, + + blur_region: BlurRegion = .empty, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + _ = alloc; + + const gtk_native = apprt_window.as(gtk.Native); + const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface; + + // This should never fail, because if we're being called at this point + // then we've already asserted that our app state is Wayland. + const gdk_wl_surface = gobject.ext.cast( + gdk_wayland.WaylandSurface, + gdk_surface, + ) orelse return error.NoWaylandSurface; + + const wl_surface: *wl.Surface = @ptrCast(@alignCast( + gdk_wl_surface.getWlSurface() orelse return error.NoWaylandSurface, + )); + + // Get our decoration object so we can control the + // CSD vs SSD status of this surface. + const deco: ?*org.KdeKwinServerDecoration = deco: { + const mgr = app.globals.get(.kde_decoration_manager) orelse + break :deco null; + + const deco: *org.KdeKwinServerDecoration = mgr.create( + wl_surface, + ) catch |err| { + log.warn("could not create decoration object={}", .{err}); + break :deco null; + }; + + break :deco deco; + }; + + const bg_effect: ?*ext.BackgroundEffectSurfaceV1 = bg: { + const mgr = app.globals.get(.ext_background_effect) orelse + break :bg null; + + const bg_effect: *ext.BackgroundEffectSurfaceV1 = mgr.getBackgroundEffect( + wl_surface, + ) catch |err| { + log.warn("could not create background effect object={}", .{err}); + break :bg null; + }; + + break :bg bg_effect; + }; + + if (apprt_window.isQuickTerminal()) { + _ = gdk.Surface.signals.enter_monitor.connect( + gdk_surface, + *ApprtWindow, + enteredMonitor, + apprt_window, + .{}, + ); + } + + return .{ + .apprt_window = apprt_window, + .surface = wl_surface, + .globals = app.globals, + .decoration = deco, + .bg_effect = bg_effect, + }; + } + + pub fn deinit(self: *Window) void { + self.blur_region.deinit(self.globals.alloc); + if (self.bg_effect) |bg| bg.destroy(); + if (self.decoration) |deco| deco.release(); + if (self.slide) |slide| slide.release(); + } + + pub fn resizeEvent(self: *Window) !void { + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + } + + pub fn syncAppearance(self: *Window) !void { + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + self.syncDecoration() catch |err| { + log.err("failed to sync decoration={}", .{err}); + }; + + if (self.apprt_window.isQuickTerminal()) { + self.syncQuickTerminal() catch |err| { + log.warn("failed to sync quick terminal appearance={}", .{err}); + }; + } + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self.getDecorationMode()) { + .Client => true, + // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs. + // However, if we do not support SSDs (e.g. GNOME) then we should enable + // CSDs even if the user prefers SSDs. + .Server => if (self.globals.get(.kde_decoration_manager)) |_| false else true, + .None => false, + else => unreachable, + }; + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + _ = self; + _ = env; + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + const activation = self.globals.get(.xdg_activation) orelse return; + + // If there already is a token, destroy and unset it + if (self.activation_token) |token| token.destroy(); + + self.activation_token = if (urgent) token: { + const token = try activation.getActivationToken(); + token.setSurface(self.surface); + token.setListener(*Window, onActivationTokenEvent, self); + token.commit(); + break :token token; + } else null; + } + + /// Update the blur state of the window. + fn syncBlur(self: *Window) !void { + const compositor = self.globals.get(.compositor) orelse return; + const bg = self.bg_effect orelse return; + if (!self.globals.state.bg_effect_capabilities.blur) return; + + const config = if (self.apprt_window.getConfig()) |v| + v.get() + else + return; + const blur = config.@"background-blur"; + + if (!blur.enabled()) { + self.blur_region.deinit(self.globals.alloc); + bg.setBlurRegion(null); + return; + } + + var region: BlurRegion = try .calcForWindow( + self.globals.alloc, + self.apprt_window, + self.clientSideDecorationEnabled(), + false, + ); + errdefer region.deinit(self.globals.alloc); + + if (region.eql(self.blur_region)) { + // Region didn't change. Don't do anything. + region.deinit(self.globals.alloc); + return; + } + + const wl_region = try compositor.createRegion(); + errdefer if (wl_region) |r| r.destroy(); + for (region.slices.items) |s| wl_region.add( + @intCast(s.x), + @intCast(s.y), + @intCast(s.width), + @intCast(s.height), + ); + + bg.setBlurRegion(wl_region); + self.blur_region = region; + } + + fn syncDecoration(self: *Window) !void { + const deco = self.decoration orelse return; + + // The protocol requests uint instead of enum so we have + // to convert it. + deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode()))); + } + + fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { + return switch (self.apprt_window.getWindowDecoration()) { + .auto => self.globals.state.default_deco_mode orelse .Client, + .client => .Client, + .server => .Server, + .none => .None, + }; + } + + fn syncQuickTerminal(self: *Window) !void { + const window = self.apprt_window.as(gtk.Window); + const config = if (self.apprt_window.getConfig()) |v| + v.get() + else + return; + + layer_shell.setLayer(window, switch (config.@"gtk-quick-terminal-layer") { + .overlay => .overlay, + .top => .top, + .bottom => .bottom, + .background => .background, + }); + layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace"); + + // Re-resolve the target monitor on every sync so that config reloads + // and primary-output changes take effect without recreating the window. + const target_monitor = resolveQuickTerminalMonitor(self.globals, self.apprt_window); + defer if (target_monitor) |v| v.unref(); + layer_shell.setMonitor(window, target_monitor); + + layer_shell.setKeyboardMode( + window, + switch (config.@"quick-terminal-keyboard-interactivity") { + .none => .none, + .@"on-demand" => on_demand: { + if (layer_shell.getProtocolVersion() < 4) { + log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); + break :on_demand .exclusive; + } + break :on_demand .on_demand; + }, + .exclusive => .exclusive, + }, + ); + + const anchored_edge: ?layer_shell.ShellEdge = switch (config.@"quick-terminal-position") { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + .center => null, + }; + + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { + if (anchored_edge) |anchored| { + if (edge == anchored) { + layer_shell.setMargin(window, edge, 0); + layer_shell.setAnchor(window, edge, true); + continue; + } + } + + // Arbitrary margin - could be made customizable? + layer_shell.setMargin(window, edge, 20); + layer_shell.setAnchor(window, edge, false); + } + + if (self.slide) |slide| slide.release(); + + self.slide = if (anchored_edge) |anchored| slide: { + const mgr = self.globals.get(.kde_slide_manager) orelse break :slide null; + + const slide = mgr.create(self.surface) catch |err| { + log.warn("could not create slide object={}", .{err}); + break :slide null; + }; + + const slide_location: org.KdeKwinSlide.Location = switch (anchored) { + .top => .top, + .bottom => .bottom, + .left => .left, + .right => .right, + }; + + slide.setLocation(@intCast(@intFromEnum(slide_location))); + slide.commit(); + break :slide slide; + } else null; + } + + /// Update the size of the quick terminal based on monitor dimensions. + fn enteredMonitor( + _: *gdk.Surface, + monitor: *gdk.Monitor, + apprt_window: *ApprtWindow, + ) callconv(.c) void { + const window = apprt_window.as(gtk.Window); + const config = if (apprt_window.getConfig()) |v| v.get() else return; + + const resolved_monitor = resolveQuickTerminalMonitor( + apprt_window.winproto().wayland.globals, + apprt_window, + ); + defer if (resolved_monitor) |v| v.unref(); + + // Use the configured monitor for sizing if not in mouse mode. + const size_monitor = resolved_monitor orelse monitor; + + var monitor_size: gdk.Rectangle = undefined; + size_monitor.getGeometry(&monitor_size); + + const dims = config.@"quick-terminal-size".calculate( + config.@"quick-terminal-position", + .{ + .width = @intCast(monitor_size.f_width), + .height = @intCast(monitor_size.f_height), + }, + ); + + window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); + } + + fn onActivationTokenEvent( + token: *xdg.ActivationTokenV1, + event: xdg.ActivationTokenV1.Event, + self: *Window, + ) void { + const activation = self.globals.get(.xdg_activation) orelse return; + const current_token = self.activation_token orelse return; + + if (token.getId() != current_token.getId()) { + log.warn("received event for unknown activation token; ignoring", .{}); + return; + } + + switch (event) { + .done => |done| { + activation.activate(done.token, self.surface); + token.destroy(); + self.activation_token = null; + }, + } + } +}; + +/// Resolve the quick-terminal-screen config to a specific monitor. +/// Returns null to let the compositor decide (used for .mouse mode). +/// Caller owns the returned ref and must unref it. +fn resolveQuickTerminalMonitor( + globals: *Globals, + apprt_window: *ApprtWindow, +) ?*gdk.Monitor { + const config = if (apprt_window.getConfig()) |v| v.get() else return null; + + switch (config.@"quick-terminal-screen") { + .mouse => return null, + .main, .@"macos-menu-bar" => {}, + } + + const display = apprt_window.as(gtk.Widget).getDisplay(); + const monitors = display.getMonitors(); + + // Try to find the monitor matching the primary output name. + if (globals.state.primary_output_name) |stored_name| { + var i: u32 = 0; + while (monitors.getObject(i)) |item| : (i += 1) { + const monitor = gobject.ext.cast(gdk.Monitor, item) orelse { + item.unref(); + continue; + }; + if (monitor.getConnector()) |connector_z| { + if (std.mem.orderZ(u8, connector_z, stored_name) == .eq) { + return monitor; + } + } + monitor.unref(); + } + } + + // Fall back to the first monitor in the list. + const first = monitors.getObject(0) orelse return null; + return gobject.ext.cast(gdk.Monitor, first) orelse { + first.unref(); + return null; + }; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig new file mode 100644 index 00000000..83052cbe --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig @@ -0,0 +1,252 @@ +const Globals = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const ext = wayland.client.ext; +const kde = wayland.client.kde; +const org = wayland.client.org; +const xdg = wayland.client.xdg; + +const log = std.log.scoped(.winproto_wayland_globals); + +alloc: Allocator, +state: State, +map: std.EnumMap(Tag, Binding), + +/// Used in the initial roundtrip to determine whether more +/// roundtrips are required to fetch the initial state. +needs_roundtrip: bool = false, + +const Binding = struct { + // All globals can be casted into a wl.Proxy object. + proxy: *wl.Proxy, + name: u32, +}; + +pub const Tag = enum { + compositor, + ext_background_effect, + kde_decoration_manager, + kde_slide_manager, + kde_output_order, + xdg_activation, + + fn Type(comptime self: Tag) type { + return switch (self) { + .compositor => wl.Compositor, + .ext_background_effect => ext.BackgroundEffectManagerV1, + .kde_decoration_manager => org.KdeKwinServerDecorationManager, + .kde_slide_manager => org.KdeKwinSlideManager, + .kde_output_order => kde.OutputOrderV1, + .xdg_activation => xdg.ActivationV1, + }; + } +}; + +pub const State = struct { + /// Connector name of the primary output (e.g., "DP-1") as reported + /// by kde_output_order_v1. The first output in each priority list + /// is the primary. + primary_output_name: ?[:0]const u8 = null, + + /// Tracks the output order event cycle. Set to true after a `done` + /// event so the next `output` event is captured as the new primary. + /// Initialized to true so the first event after binding is captured. + output_order_done: bool = true, + + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + + bg_effect_capabilities: ext.BackgroundEffectManagerV1.Capability = .{}, + + /// Reset cached state derived from kde_output_order_v1. + fn resetOutputOrder(self: *State, alloc: Allocator) void { + if (self.primary_output_name) |name| alloc.free(name); + self.primary_output_name = null; + self.output_order_done = true; + } +}; + +pub fn init(alloc: Allocator, display: *wl.Display) !*Globals { + // We need to allocate here since the listener + // expects a stable memory address. + const self = try alloc.create(Globals); + self.* = .{ + .alloc = alloc, + .state = .{}, + .map = .{}, + }; + + const registry = try display.getRegistry(); + registry.setListener(*Globals, registryListener, self); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + // Do another roundtrip to process events emitted by globals we bound + // during registry discovery (e.g. default decoration mode, output + // order). Listeners are installed at bind time in registryListener. + if (self.needs_roundtrip) { + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + } + return self; +} + +pub fn deinit(self: *Globals) void { + if (self.state.primary_output_name) |name| self.alloc.free(name); + self.alloc.destroy(self); +} + +pub fn get(self: *const Globals, comptime tag: Tag) ?*tag.Type() { + const binding = self.map.get(tag) orelse return null; + return @ptrCast(binding.proxy); +} + +fn onGlobalAttached(self: *Globals, comptime tag: Tag) void { + // Install listeners immediately at bind time. This + // keeps listener setup and object lifetime in one + // place and also supports globals that appear later. + switch (tag) { + .ext_background_effect => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, bgEffectListener, self); + self.needs_roundtrip = true; + }, + .kde_decoration_manager => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, decoManagerListener, self); + self.needs_roundtrip = true; + }, + .kde_output_order => { + const v = self.get(tag) orelse return; + v.setListener(*Globals, outputOrderListener, self); + self.needs_roundtrip = true; + }, + else => {}, + } +} + +fn onGlobalRemoved(self: *Globals, tag: Tag) void { + switch (tag) { + .kde_output_order => self.state.resetOutputOrder(self.alloc), + else => {}, + } +} + +fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + self: *Globals, +) void { + switch (event) { + .global => |v| { + log.debug("found global {s}", .{v.interface}); + inline for (comptime std.meta.tags(Tag)) |tag| { + const T = tag.Type(); + if (std.mem.orderZ(u8, v.interface, T.interface.name) == .eq) { + log.debug("matched {}", .{T}); + + const new_proxy = registry.bind( + v.name, + T, + T.generated_version, + ) catch |err| { + log.warn( + "error binding interface {s} error={}", + .{ v.interface, err }, + ); + return; + }; + + // If this global was already bound, + // then we also need to destroy the old binding. + if (self.map.get(tag)) |old| { + self.onGlobalRemoved(tag); + old.proxy.destroy(); + } + + self.map.put(tag, .{ + .proxy = @ptrCast(new_proxy), + .name = v.name, + }); + self.onGlobalAttached(tag); + } + } + }, + + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| { + var it = self.map.iterator(); + while (it.next()) |kv| { + if (kv.value.name != v.name) continue; + self.onGlobalRemoved(kv.key); + kv.value.proxy.destroy(); + self.map.remove(kv.key); + } + }, + } +} + +fn bgEffectListener( + _: *ext.BackgroundEffectManagerV1, + event: ext.BackgroundEffectManagerV1.Event, + self: *Globals, +) void { + switch (event) { + .capabilities => |cap| { + self.state.bg_effect_capabilities = cap.flags; + }, + } +} + +fn decoManagerListener( + _: *org.KdeKwinServerDecorationManager, + event: org.KdeKwinServerDecorationManager.Event, + self: *Globals, +) void { + switch (event) { + .default_mode => |mode| { + self.state.default_deco_mode = @enumFromInt(mode.mode); + }, + } +} + +fn outputOrderListener( + _: *kde.OutputOrderV1, + event: kde.OutputOrderV1.Event, + self: *Globals, +) void { + switch (event) { + .output => |v| { + // Only the first output event after a `done` is the new primary. + if (!self.state.output_order_done) return; + self.state.output_order_done = false; + + const name = std.mem.sliceTo(v.output_name, 0); + if (self.state.primary_output_name) |old| self.alloc.free(old); + + if (name.len == 0) { + self.state.primary_output_name = null; + log.warn("ignoring empty primary output name from kde_output_order_v1", .{}); + } else { + self.state.primary_output_name = self.alloc.dupeZ(u8, name) catch |err| { + self.state.primary_output_name = null; + log.warn("failed to allocate primary output name: {}", .{err}); + return; + }; + log.debug("primary output: {s}", .{name}); + } + }, + .done => { + if (self.state.output_order_done) { + // No output arrived since the previous done. Treat this as + // an empty update and drop any stale cached primary. + self.state.resetOutputOrder(self.alloc); + return; + } + self.state.output_order_done = true; + }, + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig new file mode 100644 index 00000000..8109959d --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig @@ -0,0 +1,498 @@ +//! X11 window protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); +const gdk_x11 = @import("gdk_x11"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const xlib = @import("xlib"); + +pub const c = @cImport({ + @cInclude("X11/Xlib.h"); + @cInclude("X11/Xatom.h"); + @cInclude("X11/XKBlib.h"); +}); + +const input = @import("../../../input.zig"); +const Config = @import("../../../config.zig").Config; +const ApprtWindow = @import("../class/window.zig").Window; +const BlurRegion = @import("BlurRegion.zig"); + +const log = std.log.scoped(.gtk_x11); + +pub const App = struct { + display: *xlib.Display, + base_event_code: c_int, + atoms: Atoms, + + pub fn init( + _: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + // If the display isn't X11, then we don't need to do anything. + const gdk_x11_display = gobject.ext.cast( + gdk_x11.X11Display, + gdk_display, + ) orelse return null; + + const xlib_display = gdk_x11_display.getXdisplay(); + + const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty"; + + // Set the X11 window class property (WM_CLASS) if we are on an X11 + // display. + // + // Note that we also set the program name here using g_set_prgname. + // This is how the instance name field for WM_CLASS is derived when + // calling gdk_x11_display_set_program_class; there does not seem to be + // a way to set it directly. It does not look like this is being set by + // our other app initialization routines currently, but since we're + // currently deriving its value from x11-instance-name effectively, I + // feel like gating it behind an X11 check is better intent. + // + // This makes the property show up like so when using xprop: + // + // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" + // + // Append "-debug" on both when using the debug build. + glib.setPrgname(x11_program_name); + gdk_x11.X11Display.setProgramClass(gdk_display, app_id); + + // XKB + log.debug("Xkb.init: initializing Xkb", .{}); + log.debug("Xkb.init: running XkbQueryExtension", .{}); + var opcode: c_int = 0; + var base_event_code: c_int = 0; + var base_error_code: c_int = 0; + var major = c.XkbMajorVersion; + var minor = c.XkbMinorVersion; + if (c.XkbQueryExtension( + @ptrCast(@alignCast(xlib_display)), + &opcode, + &base_event_code, + &base_error_code, + &major, + &minor, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); + return error.XkbInitializationError; + } + + log.debug("Xkb.init: running XkbSelectEventDetails", .{}); + if (c.XkbSelectEventDetails( + @ptrCast(@alignCast(xlib_display)), + c.XkbUseCoreKbd, + c.XkbStateNotify, + c.XkbModifierStateMask, + c.XkbModifierStateMask, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); + return error.XkbInitializationError; + } + + return .{ + .display = xlib_display, + .base_event_code = base_event_code, + .atoms = .init(gdk_x11_display), + }; + } + + pub fn deinit(self: *App) void { + _ = self; + } + + /// Checks for an immediate pending XKB state update event, and returns the + /// keyboard state based on if it finds any. This is necessary as the + /// standard GTK X11 API (and X11 in general) does not include the current + /// key pressed in any modifier state snapshot for that event (e.g. if the + /// pressed key is a modifier, that is not necessarily reflected in the + /// modifiers). + /// + /// Returns null if there is no event. In this case, the caller should fall + /// back to the standard GDK modifier state (this likely means the key + /// event did not result in a modifier change). + pub fn eventMods( + self: App, + device: ?*gdk.Device, + gtk_mods: gdk.ModifierType, + ) ?input.Mods { + _ = device; + _ = gtk_mods; + + // Shoutout to Mozilla for figuring out a clean way to do this, this is + // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. + if (c.XEventsQueued( + @ptrCast(@alignCast(self.display)), + c.QueuedAfterReading, + ) == 0) return null; + + var nextEvent: c.XEvent = undefined; + _ = c.XPeekEvent(@ptrCast(@alignCast(self.display)), &nextEvent); + if (nextEvent.type != self.base_event_code) return null; + + const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); + if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; + + const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); + // Check the state according to XKB masks. + const lookup_mods = xkb_state_notify_event.lookup_mods; + var mods: input.Mods = .{}; + + log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); + if (lookup_mods & c.ShiftMask != 0) mods.shift = true; + if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; + if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; + if (lookup_mods & c.Mod4Mask != 0) mods.super = true; + if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; + + return mods; + } + + pub fn supportsQuickTerminal(_: App) bool { + log.warn("quick terminal is not yet supported on X11", .{}); + return false; + } + + pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} +}; + +pub const Window = struct { + app: *App, + apprt_window: *ApprtWindow, + x11_surface: *gdk_x11.X11Surface, + alloc: Allocator, + + blur_region: BlurRegion = .empty, + + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 + last_applied_decoration_hints: ?MotifWMHints = null, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + const surface = apprt_window.as(gtk.Native).getSurface() orelse + return error.NotX11Surface; + + const x11_surface = gobject.ext.cast( + gdk_x11.X11Surface, + surface, + ) orelse return error.NotX11Surface; + + return .{ + .app = app, + .alloc = alloc, + .apprt_window = apprt_window, + .x11_surface = x11_surface, + }; + } + + pub fn deinit(self: *Window) void { + self.blur_region.deinit(self.alloc); + } + + pub fn resizeEvent(self: *Window) !void { + // The blur region must update with window resizes + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + } + + pub fn syncAppearance(self: *Window) !void { + // The user could have toggled between CSDs and SSDs, + // therefore we need to recalculate the blur region offset. + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + self.syncDecorations() catch |err| { + log.err("failed to sync decorations={}", .{err}); + }; + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self.apprt_window.getWindowDecoration()) { + .auto, .client => true, + .server, .none => false, + }; + } + + fn syncBlur(self: *Window) !void { + const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + + var region: BlurRegion = if (blur.enabled()) + try .calcForWindow( + self.alloc, + self.apprt_window, + self.clientSideDecorationEnabled(), + true, + ) + else + .empty; + errdefer region.deinit(self.alloc); + + // Only update X11 properties when the blur region actually changes + if (region.eql(self.blur_region)) { + region.deinit(self.alloc); + return; + } + + if (region.slices.items.len > 0) { + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.x11_surface.getXid(), + region, + }); + + try self.changeProperty( + BlurRegion.Slice, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + region.slices.items, + ); + } else { + try self.deleteProperty(self.app.atoms.kde_blur); + } + + self.blur_region.deinit(self.alloc); + self.blur_region = region; + } + + fn syncDecorations(self: *Window) !void { + var hints: MotifWMHints = .{}; + + self.getWindowProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{}, + &hints, + ) catch |err| switch (err) { + // motif_wm_hints is already initialized, so this is fine + error.PropertyNotFound => {}, + + error.RequestFailed, + error.PropertyTypeMismatch, + error.PropertyFormatMismatch, + => return err, + }; + + hints.flags.decorations = true; + hints.decorations.all = switch (self.apprt_window.getWindowDecoration()) { + .server => true, + .auto, .client, .none => false, + }; + + // Only update decoration hints when they actually change + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; + } + + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &.{hints}, + ); + self.last_applied_decoration_hints = hints; + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + var buf: [64]u8 = undefined; + const window_id = try std.fmt.bufPrint( + &buf, + "{}", + .{self.x11_surface.getXid()}, + ); + + try env.put("WINDOWID", window_id); + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + self.x11_surface.setUrgencyHint(@intFromBool(urgent)); + } + + fn getWindowProperty( + self: *Window, + comptime T: type, + name: c.Atom, + typ: c.Atom, + comptime format: PropertyFormat, + options: struct { + offset: c_long = 0, + length: c_long = std.math.maxInt(c_long), + delete: bool = false, + }, + result: *T, + ) GetWindowPropertyError!void { + // FIXME: Maybe we should switch to libxcb one day. + // Sounds like a much better idea than whatever this is + var actual_type_return: c.Atom = undefined; + var actual_format_return: c_int = undefined; + var nitems_return: c_ulong = undefined; + var bytes_after_return: c_ulong = undefined; + var prop_return: ?format.bufferType() = null; + + const code = c.XGetWindowProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + options.offset, + options.length, + @intFromBool(options.delete), + typ, + &actual_type_return, + &actual_format_return, + &nitems_return, + &bytes_after_return, + @ptrCast(&prop_return), + ); + if (code != c.Success) return error.RequestFailed; + + if (actual_type_return == c.None) return error.PropertyNotFound; + if (typ != actual_type_return) return error.PropertyTypeMismatch; + if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch; + + const data_ptr: *T = @ptrCast(prop_return); + result.* = data_ptr.*; + _ = c.XFree(prop_return); + } + + fn changeProperty( + self: *Window, + comptime T: type, + name: c.Atom, + typ: c.Atom, + comptime format: PropertyFormat, + options: struct { + mode: PropertyChangeMode, + }, + values: []const T, + ) X11Error!void { + const data: format.bufferType() = @ptrCast(@constCast(values)); + // The number of "words" that each element `T` occupies. + const words_per_elem = @divExact(@sizeOf(T), @sizeOf(format.elemType())); + + const status = c.XChangeProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + typ, + @intFromEnum(format), + @intFromEnum(options.mode), + data, + @intCast(words_per_elem * values.len), + ); + + // For some godforsaken reason Xlib alternates between + // error values (0 = success) and booleans (1 = success), and they look exactly + // the same in the signature (just `int`, since Xlib is written in C89)... + if (status == 0) return error.RequestFailed; + } + + fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { + const status = c.XDeleteProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + ); + if (status == 0) return error.RequestFailed; + } +}; + +const X11Error = error{ + RequestFailed, +}; + +const GetWindowPropertyError = X11Error || error{ + PropertyNotFound, + PropertyTypeMismatch, + PropertyFormatMismatch, +}; + +const Atoms = struct { + kde_blur: c.Atom, + motif_wm_hints: c.Atom, + + fn init(display: *gdk_x11.X11Display) Atoms { + return .{ + .kde_blur = gdk_x11.x11GetXatomByNameForDisplay( + display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + .motif_wm_hints = gdk_x11.x11GetXatomByNameForDisplay( + display, + "_MOTIF_WM_HINTS", + ), + }; + } +}; + +const PropertyChangeMode = enum(c_int) { + replace = c.PropModeReplace, + prepend = c.PropModePrepend, + append = c.PropModeAppend, +}; + +const PropertyFormat = enum(c_int) { + _8 = 8, + _16 = 16, + _32 = 32, + + fn elemType(comptime self: PropertyFormat) type { + return switch (self) { + ._8 => c_char, + ._16 => c_int, + ._32 => c_long, + }; + } + + fn bufferType(comptime self: PropertyFormat) type { + // The buffer type has to be a multi-pointer to bytes + // *aligned to the element type* (very important, + // otherwise you'll read garbage!) + // + // I know this is really ugly. X11 is ugly. I consider it apropos. + return [*]align(@alignOf(self.elemType())) u8; + } +}; + +// See Xm/MwmUtil.h, packaged with the Motif Window Manager +const MotifWMHints = extern struct { + flags: packed struct(c_ulong) { + _pad: u1 = 0, + decorations: bool = false, + + // We don't really care about the other flags + _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0, + } = .{}, + functions: c_ulong = 0, + decorations: packed struct(c_ulong) { + all: bool = false, + + // We don't really care about the other flags + _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0, + } = .{}, + input_mode: c_long = 0, + status: c_ulong = 0, +}; diff --git a/vendor/libghostty-vt/src/apprt/ipc.zig b/vendor/libghostty-vt/src/apprt/ipc.zig new file mode 100644 index 00000000..b37647e0 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/ipc.zig @@ -0,0 +1,192 @@ +//! Inter-process Communication to a running Ghostty instance from a separate +//! process. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const lib = @import("../lib/main.zig"); + +pub const Errors = error{ + /// The IPC failed. If a function returns this error, it's expected that + /// an a more specific error message will have been written to stderr (or + /// otherwise shown to the user in an appropriate way). + IPCFailed, +}; + +pub const Target = union(Key) { + /// Open up a new window in a custom instance of Ghostty. + class: [:0]const u8, + + /// Detect which instance to open a new window in. + detect, + + // Sync with: ghostty_ipc_target_tag_e + pub const Key = enum(c_int) { + class, + detect, + + test "ghostty.h Target.Key" { + try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_TARGET_"); + } + }; + + // Sync with: ghostty_ipc_target_u + pub const CValue = extern union { + class: [*:0]const u8, + detect: void, + }; + + // Sync with: ghostty_ipc_target_s + pub const C = extern struct { + key: Key, + value: CValue, + }; + + /// Convert to ghostty_ipc_target_s. + pub fn cval(self: Target) C { + return .{ + .key = @as(Key, self), + .value = switch (self) { + .class => |class| .{ .class = class.ptr }, + .detect => .{ .detect = {} }, + }, + }; + } +}; + +pub const Action = union(enum) { + // A GUIDE TO ADDING NEW ACTIONS: + // + // 1. Add the action to the `Key` enum. The order of the enum matters + // because it maps directly to the libghostty C enum. For ABI + // compatibility, new actions should be added to the end of the enum. + // + // 2. Add the action and optional value to the Action union. + // + // 3. If the value type is not void, ensure the value is C ABI + // compatible (extern). If it is not, add a `C` decl to the value + // and a `cval` function to convert to the C ABI compatible value. + // + // 4. Update `include/ghostty.h`: add the new key, value, and union + // entry. If the value type is void then only the key needs to be + // added. Ensure the order matches exactly with the Zig code. + + /// The arguments to pass to Ghostty as the command. + new_window: NewWindow, + + pub const NewWindow = struct { + /// A list of command arguments to launch in the new window. If this is + /// `null` the command configured in the config or the user's default + /// shell should be launched. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[][:0]const u8, + + pub const C = extern struct { + /// null terminated list of arguments + /// it will be null itself if there are no arguments + arguments: ?[*]?[*:0]const u8, + + pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { + if (self.arguments) |arguments| alloc.free(arguments); + } + }; + + pub fn cval(self: *NewWindow, alloc: Allocator) Allocator.Error!NewWindow.C { + var result: NewWindow.C = undefined; + + if (self.arguments) |arguments| { + result.arguments = try alloc.alloc([*:0]const u8, arguments.len + 1); + + for (arguments, 0..) |argument, i| + result.arguments[i] = argument.ptr; + + // add null terminator + result.arguments[arguments.len] = null; + } else { + result.arguments = null; + } + + return result; + } + }; + + /// Sync with: ghostty_ipc_action_tag_e + pub const Key = enum(c_int) { + new_window, + + test "ghostty.h Action.Key" { + try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_ACTION_"); + } + }; + + /// Sync with: ghostty_ipc_action_u + pub const CValue = cvalue: { + const key_fields = @typeInfo(Key).@"enum".fields; + var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined; + for (key_fields, 0..) |field, i| { + const action = @unionInit(Action, field.name, undefined); + const Type = t: { + const Type = @TypeOf(@field(action, field.name)); + // Types can provide custom types for their CValue. + if (Type != void and @hasDecl(Type, "C")) break :t Type.C; + break :t Type; + }; + + union_fields[i] = .{ + .name = field.name, + .type = Type, + .alignment = @alignOf(Type), + }; + } + + break :cvalue @Type(.{ .@"union" = .{ + .layout = .@"extern", + .tag_type = null, + .fields = &union_fields, + .decls = &.{}, + } }); + }; + + /// Sync with: ghostty_ipc_action_s + pub const C = extern struct { + key: Key, + value: CValue, + }; + + comptime { + // For ABI compatibility, we expect that this is our union size. + // At the time of writing, we don't promise ABI compatibility + // so we can change this but I want to be aware of it. + assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { + 4 => 4, + 8 => 8, + else => unreachable, + }); + } + + /// Returns the value type for the given key. + pub fn Value(comptime key: Key) type { + inline for (@typeInfo(Action).@"union".fields) |field| { + const field_key = @field(Key, field.name); + if (field_key == key) return field.type; + } + + unreachable; + } + + /// Convert to ghostty_ipc_action_s. + pub fn cval(self: Action, alloc: Allocator) C { + const value: CValue = switch (self) { + inline else => |v, tag| @unionInit( + CValue, + @tagName(tag), + if (@TypeOf(v) != void and @hasDecl(@TypeOf(v), "cval")) v.cval(alloc) else v, + ), + }; + + return .{ + .key = @as(Key, self), + .value = value, + }; + } +}; diff --git a/vendor/libghostty-vt/src/apprt/none.zig b/vendor/libghostty-vt/src/apprt/none.zig new file mode 100644 index 00000000..0483d824 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/none.zig @@ -0,0 +1,19 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const internal_os = @import("../os/main.zig"); +const apprt = @import("../apprt.zig"); +pub const resourcesDir = internal_os.resourcesDir; + +pub const App = struct { + /// Always return false as there is no apprt to communicate with. + pub fn performIpc( + _: Allocator, + _: apprt.ipc.Target, + comptime action: apprt.ipc.Action.Key, + _: apprt.ipc.Action.Value(action), + ) !bool { + return false; + } +}; +pub const Surface = struct {}; diff --git a/vendor/libghostty-vt/src/apprt/runtime.zig b/vendor/libghostty-vt/src/apprt/runtime.zig new file mode 100644 index 00000000..1be50353 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/runtime.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +/// Runtime is the runtime to use for Ghostty. All runtimes do not provide +/// equivalent feature sets. +pub const Runtime = enum { + /// Will not produce an executable at all when `zig build` is called. + /// This is only useful if you're only interested in the lib only (macOS). + none, + + /// GTK4. Rich windowed application. This uses a full GObject-based + /// approach to building the application. + gtk, + + pub fn default(target: std.Target) Runtime { + return switch (target.os.tag) { + // The Linux and FreeBSD default is GTK because it is a full + // featured application. + .linux, .freebsd => .gtk, + // Otherwise, we do NONE so we don't create an exe and we create + // libghostty. On macOS, Xcode is used to build the app that links + // to libghostty. + else => .none, + }; + } +}; + +test { + _ = Runtime; +} diff --git a/vendor/libghostty-vt/src/apprt/structs.zig b/vendor/libghostty-vt/src/apprt/structs.zig new file mode 100644 index 00000000..2c37dbd5 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/structs.zig @@ -0,0 +1,114 @@ +const build_config = @import("../build_config.zig"); + +/// ContentScale is the ratio between the current DPI and the platform's +/// default DPI. This is used to determine how much certain rendered elements +/// need to be scaled up or down. +pub const ContentScale = struct { + x: f32, + y: f32, +}; + +/// The size of the surface in pixels. +pub const SurfaceSize = struct { + width: u32, + height: u32, + + pub fn eql(self: *const SurfaceSize, other: *const SurfaceSize) bool { + return self.width == other.width and self.height == other.height; + } +}; + +/// The position of the cursor in pixels. +pub const CursorPos = struct { + x: f32, + y: f32, +}; + +/// Input Method Editor (IME) position. +pub const IMEPos = struct { + x: f64, + y: f64, + width: f64, + height: f64, +}; + +/// The clipboard type. +/// +/// If this is changed, you must also update ghostty.h +pub const Clipboard = enum(Backing) { + standard = 0, // ctrl+c/v + selection = 1, + primary = 2, + + // Our backing isn't is as small as we can in Zig, but a full + // C int if we're binding to C APIs. + const Backing = switch (build_config.app_runtime) { + .gtk => c_int, + else => u2, + }; + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineEnum( + Clipboard, + .{ .name = "GhosttyApprtClipboard" }, + ), + + .none => void, + }; +}; + +pub const ClipboardContent = struct { + mime: [:0]const u8, + data: [:0]const u8, +}; + +pub const ClipboardRequestType = enum(u8) { + paste, + osc_52_read, + osc_52_write, +}; + +/// Clipboard request. This is used to request clipboard contents and must +/// be sent as a response to a ClipboardRequest event. +pub const ClipboardRequest = union(ClipboardRequestType) { + /// A direct paste of clipboard contents. + paste: void, + + /// A request to read clipboard contents via OSC 52. + osc_52_read: Clipboard, + + /// A request to write clipboard contents via OSC 52. + osc_52_write: Clipboard, + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk => @import("gobject").ext.defineBoxed( + ClipboardRequest, + .{ .name = "GhosttyClipboardRequest" }, + ), + + .none => void, + }; +}; + +/// The color scheme in use (light vs dark). +pub const ColorScheme = enum(u2) { + light = 0, + dark = 1, +}; + +/// Selection information +pub const Selection = struct { + /// Top-left point of the selection in the viewport in scaled + /// window pixels. (0,0) is the top-left of the window. + tl_x_px: f64, + tl_y_px: f64, + + /// The offset of the selection start in cells from the top-left + /// of the viewport. + /// + /// This is a strange metric but its used by macOS. + offset_start: u32, + offset_len: u32, +}; diff --git a/vendor/libghostty-vt/src/apprt/surface.zig b/vendor/libghostty-vt/src/apprt/surface.zig new file mode 100644 index 00000000..3cb0016f --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/surface.zig @@ -0,0 +1,197 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const apprt = @import("../apprt.zig"); +const build_config = @import("../build_config.zig"); +const App = @import("../App.zig"); +const Surface = @import("../Surface.zig"); +const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); +const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; + +/// The message types that can be sent to a single surface. +pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + + /// Set the title of the surface. + /// TODO: we should change this to a "WriteReq" style structure in + /// the termio message so that we can more efficiently send strings + /// of any length + set_title: [256]u8, + + /// Report the window title back to the terminal + report_title: ReportTitleStyle, + + /// Set the mouse shape. + set_mouse_shape: terminal.MouseShape, + + /// Read the clipboard and write to the pty. + clipboard_read: apprt.Clipboard, + + /// Write the clipboard contents. + clipboard_write: struct { + clipboard_type: apprt.Clipboard, + req: WriteReq, + }, + + /// Change the configuration to the given configuration. The pointer is + /// not valid after receiving this message so any config must be used + /// and derived immediately. + change_config: *const Config, + + /// Close the surface. This will only close the current surface that + /// receives this, not the full application. + close: void, + + /// The child process running in the surface has exited. This may trigger + /// a surface close, it may not. Additional details about the child + /// command are given in the `ChildExited` struct. + child_exited: ChildExited, + + /// Show a desktop notification. + desktop_notification: struct { + /// Desktop notification title. + title: [63:0]u8, + + /// Desktop notification body. + body: [255:0]u8, + }, + + /// Health status change for the renderer. + renderer_health: renderer.Health, + + /// Tell the surface to present itself to the user. This may require raising + /// a window and switching tabs. + present_surface: void, + + /// Notifies the surface that password input has started within + /// the terminal. This should always be followed by a false value + /// unless the surface exits. + password_input: bool, + + /// A terminal color was changed using OSC sequences. + color_change: terminal.osc.color.ColoredTarget, + + /// Notifies the surface that a tick of the timer that is timing + /// out selection scrolling has occurred. "selection scrolling" + /// is when the user has clicked and dragged the mouse outside + /// the viewport of the terminal and the terminal is scrolling + /// the viewport to follow the mouse cursor. + selection_scroll_tick: bool, + + /// The terminal has reported a change in the working directory. + pwd_change: WriteReq, + + /// The terminal encountered a bell character. + ring_bell, + + /// Report the progress of an action using a GUI element + progress_report: terminal.osc.Command.ProgressReport, + + /// A command has started in the shell, start a timer. + start_command, + + /// A command has finished in the shell, stop the timer and send out + /// notifications as appropriate. The optional u8 is the exit code + /// of the command. + stop_command: ?u8, + + /// The scrollbar state changed for the surface. + scrollbar: terminal.Scrollbar, + + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + + pub const ReportTitleStyle = enum { + csi_21_t, + + // This enum is a placeholder for future title styles. + }; + + pub const ChildExited = extern struct { + exit_code: u32, + runtime_ms: u64, + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, + => @import("gobject").ext.defineBoxed( + ChildExited, + .{ .name = "GhosttyApprtChildExited" }, + ), + + .none => void, + }; + }; +}; + +/// A surface mailbox. +pub const Mailbox = struct { + surface: *Surface, + app: App.Mailbox, + + /// Send a message to the surface. + pub fn push( + self: Mailbox, + msg: Message, + timeout: App.Mailbox.Queue.Timeout, + ) App.Mailbox.Queue.Size { + // Surface message sending is actually implemented on the app + // thread, so we have to rewrap the message with our surface + // pointer and send it to the app thread. + return self.app.push(.{ + .surface_message = .{ + .surface = self.surface, + .message = msg, + }, + }, timeout); + } +}; + +/// Context for new surface creation to determine inheritance behavior +pub const NewSurfaceContext = enum(c_int) { + window = 0, + tab = 1, + split = 2, +}; + +pub fn shouldInheritWorkingDirectory(context: NewSurfaceContext, config: *const Config) bool { + return switch (context) { + .window => config.@"window-inherit-working-directory", + .tab => config.@"tab-inherit-working-directory", + .split => config.@"split-inherit-working-directory", + }; +} + +/// Returns a new config for a surface for the given app that should be +/// used for any new surfaces. The resulting config should be deinitialized +/// after the surface is initialized. +pub fn newConfig( + app: *const App, + config: *const Config, + context: NewSurfaceContext, +) Allocator.Error!Config { + // Create a shallow clone + var copy = config.shallowClone(app.alloc); + + // Our allocator is our config's arena + const alloc = copy._arena.?.allocator(); + + // Get our previously focused surface for some inherited values. + const prev = app.focusedSurface(); + if (prev) |p| { + if (shouldInheritWorkingDirectory(context, config)) { + if (try p.pwd(alloc)) |pwd| { + copy.@"working-directory" = .{ .path = pwd }; + } + } + } + + return copy; +} |
