mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Adds progress-style config to control OSC 9;4 progress bar visibility. Defaults to true, set false to hide. Fixes #11241 AI Disclosure: Claude Code (Opus 4.6) used for codebase exploration, code review, and testing assistance. All code written and reviewed by hand.
4095 lines
146 KiB
Zig
4095 lines
146 KiB
Zig
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 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(@intFromPtr(core_surface));
|
|
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,
|
|
);
|
|
|
|
assert(std.fs.path.isAbsolute(path));
|
|
const media_file = gtk.MediaFile.newForFilename(path);
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// 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 {
|
|
const priv = self.private();
|
|
priv.focused = true;
|
|
priv.im_context.as(gtk.IMContext).focusIn();
|
|
_ = glib.idleAddOnce(idleFocus, self.ref());
|
|
self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec);
|
|
|
|
// Bell stops ringing as soon as we gain focus
|
|
self.setBellRinging(false);
|
|
}
|
|
|
|
fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
priv.focused = false;
|
|
priv.im_context.as(gtk.IMContext).focusOut();
|
|
_ = glib.idleAddOnce(idleFocus, self.ref());
|
|
self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec);
|
|
}
|
|
|
|
/// 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();
|
|
priv.size = .{
|
|
.width = @intCast(width),
|
|
.height = @intCast(height),
|
|
};
|
|
|
|
// 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});
|
|
};
|
|
|
|
surface.sizeCallback(priv.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,
|
|
);
|
|
}
|
|
|
|
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 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 bell 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();
|
|
}
|
|
|
|
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));
|
|
}
|