mirror-ghostty/src/apprt/gtk/class/surface.zig

3148 lines
110 KiB
Zig

const std = @import("std");
const assert = std.debug.assert;
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 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 adw_version = @import("../adw_version.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 ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
const Window = @import("window.zig").Window;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
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 getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurface",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
/// A SplitTree implementation that stores surfaces.
pub const Tree = datastruct.SplitTree(Self);
pub const properties = struct {
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 signals = struct {
/// 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 cgroup created for this surface. This will be created
/// if `Application.transient_cgroup_base` is set.
cgroup_path: ?[]const u8 = 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 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,
// 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,
pub var offset: c_int = 0;
};
pub fn new() *Self {
return gobject.ext.newInstance(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,
) 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;
}
// 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| {
const config = config_obj.get();
// Setup our pwd if configured to inherit
if (config.@"window-inherit-working-directory") {
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);
}
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();
}
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 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;
}
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,
);
}
/// 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;
// If the key should be remappable, then consult the pre-remapped
// XKB keyval/keysym to get the (possibly) remapped key.
//
// See the docs for `shouldBeRemappable` for why we even have to
// do this in the first place.
if (w3c_key.shouldBeRemappable()) {
if (gtk_key.keyFromKeyval(keyval)) |remapped|
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 = gobject.ext.newInstance(
TitleDialog,
.{
.@"initial-value" = 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,
};
}
/// Initialize the cgroup for this surface if it hasn't been
/// already. While this is `init`-prefixed, we prefer to call this
/// in the realize function because we don't need to create a cgroup
/// if we don't init a surface.
fn initCgroup(self: *Self) void {
const priv = self.private();
// If we already have a cgroup path then we don't do it again.
if (priv.cgroup_path != null) return;
const app = Application.default();
const alloc = app.allocator();
const base = app.cgroupBase() orelse return;
// For the unique group name we use the self pointer. This may
// not be a good idea for security reasons but not sure yet. We
// may want to change this to something else eventually to be safe.
var buf: [256]u8 = undefined;
const name = std.fmt.bufPrint(
&buf,
"surfaces/{X}.scope",
.{@intFromPtr(self)},
) catch unreachable;
// Create the cgroup. If it fails, no big deal... just ignore.
internal_os.cgroup.create(base, name, null) catch |err| {
log.warn("failed to create surface cgroup err={}", .{err});
return;
};
// Success, save the cgroup path.
priv.cgroup_path = std.fmt.allocPrint(
alloc,
"{s}/{s}",
.{ base, name },
) catch null;
}
/// Deletes the cgroup if set.
fn clearCgroup(self: *Self) void {
const priv = self.private();
const path = priv.cgroup_path orelse return;
internal_os.cgroup.remove(path) catch |err| {
// We don't want this to be fatal in any way so we just log
// and continue. A dangling empty cgroup is not a big deal
// and this should be rare.
log.warn(
"failed to remove cgroup for surface path={s} err={}",
.{ path, err },
);
};
Application.default().allocator().free(path);
priv.cgroup_path = null;
}
//---------------------------------------------------------------
// 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 cgroupPath(self: *Self) ?[]const u8 {
return self.private().cgroup_path;
}
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 settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0;
var value = std.mem.zeroes(gobject.Value);
defer value.unset();
_ = value.init(gobject.ext.typeFor(c_int));
settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value);
const gtk_xft_dpi = value.getInt();
// 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 was not set, using default value", .{});
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 alloc = Application.default().allocator();
var env = try internal_os.getEnvMap(alloc);
errdefer env.deinit();
// 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 (env.get("SNAP")) |_| {
env.remove("SNAP");
env.remove("DRIRC_CONFIGDIR");
env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS");
env.remove("__EGL_VENDOR_LIBRARY_DIRS");
env.remove("LD_LIBRARY_PATH");
env.remove("LIBGL_DRIVERS_PATH");
env.remove("LIBVA_DRIVERS_PATH");
env.remove("VK_LAYER_PATH");
env.remove("XLOCALEDIR");
env.remove("GDK_PIXBUF_MODULEDIR");
env.remove("GDK_PIXBUF_MODULE_FILE");
env.remove("GTK_PATH");
}
// 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);
}
return env;
}
pub fn clipboardRequest(
self: *Self,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) !void {
try Clipboard.request(
self,
clipboard_type,
state,
);
}
pub fn setClipboardString(
self: *Self,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
confirm: bool,
) void {
Clipboard.set(
self,
val,
clipboard_type,
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();
}
//---------------------------------------------------------------
// 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 };
// 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;
// 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);
// Initialize our GLArea. We only set the values we can't set
// in our blueprint file.
const gl_area = priv.gl_area;
gl_area.setRequiredVersion(
renderer.OpenGL.MIN_VERSION_MAJOR,
renderer.OpenGL.MIN_VERSION_MINOR,
);
self.as(gtk.Widget).setCursorFromName("text");
// Initialize our config
self.propConfig(undefined, null);
}
fn initActionMap(self: *Self) void {
const actions = [_]ext.actions.Action(Self){
.init("prompt-title", actionPromptTitle, null),
};
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.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;
}
// 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 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();
const alloc = Application.default().allocator();
alloc.destroy(v);
priv.core_surface = null;
}
if (priv.mouse_hover_url) |v| {
glib.free(@constCast(@ptrCast(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(@constCast(@ptrCast(v)));
priv.pwd = null;
}
if (priv.title) |v| {
glib.free(@constCast(@ptrCast(v)));
priv.title = null;
}
if (priv.title_override) |v| {
glib.free(@constCast(@ptrCast(v)));
priv.title_override = null;
}
self.clearCgroup();
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;
}
/// 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(@constCast(@ptrCast(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(@constCast(@ptrCast(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(@constCast(@ptrCast(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);
}
/// 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(@constCast(@ptrCast(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 {
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);
}
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 propBellRinging(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
if (!priv.bell_ringing) return;
// Activate actions if they exist
_ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
_ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
// Do our sound
const config = if (priv.config) |c| c.get() else return;
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();
}
}
//---------------------------------------------------------------
// 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});
};
}
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 data = std.ArrayList(u8).init(alloc);
defer data.deinit();
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
.child_writer = data.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 = data.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 data = std.ArrayList(u8).init(alloc);
defer data.deinit();
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
.child_writer = data.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 = data.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);
if (gl_area_widget.hasFocus() == 0) {
_ = gl_area_widget.grabFocus();
}
// Report the event
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
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());
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 ecMouseScrollPrecisionBegin(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = true;
}
fn ecMouseScrollPrecisionEnd(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = false;
}
fn ecMouseScroll(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *Self,
) callconv(.c) c_int {
const priv = 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 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 = 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();
// Initialize our cgroup if we can.
self.initCgroup();
errdefer self.clearCgroup();
// 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(),
);
defer config.deinit();
// Properties that can impact surface init
if (priv.font_size_request) |size| config.@"font-size" = size.points;
if (priv.pwd) |pwd| config.@"working-directory" = pwd;
// 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);
}
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(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("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", &ecMouseScroll);
class.bindTemplateCallback("scroll_begin", &ecMouseScrollPrecisionBegin);
class.bindTemplateCallback("scroll_end", &ecMouseScrollPrecisionEnd);
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_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
// 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.@"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,
});
// Signals
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,
val: [:0]const u8,
clipboard_type: apprt.Clipboard,
confirm: bool,
) void {
const priv = self.private();
// If no confirmation is necessary, set the clipboard.
if (!confirm) {
const clipboard = get(
priv.gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
clipboard.setText(val);
Surface.signals.@"clipboard-write".impl.emit(
self,
null,
.{ clipboard_type, val.ptr },
null,
);
return;
}
showClipboardConfirmation(
self,
.{ .osc_52_write = clipboard_type },
val,
);
}
/// Request data from the clipboard (read the clipboard). This
/// completes asynchronously and will call the `completeClipboardRequest`
/// core surface API when done.
pub fn request(
self: *Surface,
clipboard_type: apprt.Clipboard,
state: apprt.ClipboardRequest,
) Allocator.Error!void {
// Get our requested clipboard
const clipboard = get(
self.private().gl_area.as(gtk.Widget),
clipboard_type,
) orelse return;
// 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,
);
}
/// 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));
}