mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-25 13:16:11 +03:00
2128 lines
73 KiB
Zig
2128 lines
73 KiB
Zig
const std = @import("std");
|
|
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 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 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 ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
|
|
|
|
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 },
|
|
});
|
|
|
|
pub const properties = struct {
|
|
pub const config = struct {
|
|
pub const name = "config";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*Config,
|
|
.{
|
|
.nick = "Config",
|
|
.blurb = "The configuration that this surface is using.",
|
|
.accessor = gobject.ext.privateFieldAccessor(
|
|
Self,
|
|
Private,
|
|
&Private.offset,
|
|
"config",
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const focused = struct {
|
|
pub const name = "focused";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.nick = "Focused",
|
|
.blurb = "The focused state of the surface.",
|
|
.default = false,
|
|
.accessor = gobject.ext.privateFieldAccessor(
|
|
Self,
|
|
Private,
|
|
&Private.offset,
|
|
"focused",
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"mouse-hidden" = struct {
|
|
pub const name = "mouse-hidden";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.nick = "Mouse Hidden",
|
|
.blurb = "Whether the mouse cursor should be hidden.",
|
|
.default = false,
|
|
.accessor = gobject.ext.privateFieldAccessor(
|
|
Self,
|
|
Private,
|
|
&Private.offset,
|
|
"mouse_hidden",
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"mouse-shape" = struct {
|
|
pub const name = "mouse-shape";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
terminal.MouseShape,
|
|
.{
|
|
.nick = "Mouse Shape",
|
|
.blurb = "The current mouse shape to show for the surface.",
|
|
.default = .text,
|
|
.accessor = gobject.ext.privateFieldAccessor(
|
|
Self,
|
|
Private,
|
|
&Private.offset,
|
|
"mouse_shape",
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"mouse-hover-url" = struct {
|
|
pub const name = "mouse-hover-url";
|
|
pub const get = impl.get;
|
|
pub const set = impl.set;
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.nick = "Mouse Hover URL",
|
|
.blurb = "The URL the mouse is currently hovering over (if any).",
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("mouse_hover_url"),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const pwd = struct {
|
|
pub const name = "pwd";
|
|
pub const get = impl.get;
|
|
pub const set = impl.set;
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.nick = "Working Directory",
|
|
.blurb = "The current working directory as reported by core.",
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("pwd"),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const title = struct {
|
|
pub const name = "title";
|
|
pub const get = impl.get;
|
|
pub const set = impl.set;
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.nick = "Title",
|
|
.blurb = "The title of the surface.",
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("title"),
|
|
},
|
|
);
|
|
};
|
|
};
|
|
|
|
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,
|
|
&.{bool},
|
|
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 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.
|
|
pwd: ?[:0]const u8 = null,
|
|
|
|
/// The title of this surface, if any has been set.
|
|
title: ?[:0]const u8 = null,
|
|
|
|
/// The current focus state of the terminal based on the
|
|
/// focus events.
|
|
focused: bool = true,
|
|
|
|
/// The overlay we use for things such as the URL hover label
|
|
/// or resize box. Bound from the template.
|
|
overlay: *gtk.Overlay = undefined,
|
|
|
|
/// 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 = undefined,
|
|
|
|
/// The labels for the left/right sides of the URL hover tooltip.
|
|
url_left: *gtk.Label = undefined,
|
|
url_right: *gtk.Label = undefined,
|
|
|
|
/// The resize overlay
|
|
resize_overlay: *ResizeOverlay = undefined,
|
|
|
|
/// 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 = null,
|
|
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,
|
|
|
|
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;
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// 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.
|
|
if (priv.im_context) |im_context| {
|
|
// 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();
|
|
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 = 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: for (input.keycodes.entries) |entry| {
|
|
if (entry.native == keycode) break :keycode entry.key;
|
|
} else .unidentified;
|
|
|
|
// 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) {
|
|
if (priv.im_context) |im_context| {
|
|
im_context.as(gtk.IMContext).reset();
|
|
}
|
|
|
|
surface.preeditCallback(null) catch {};
|
|
}
|
|
|
|
return true;
|
|
},
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// 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, process_active: bool) void {
|
|
signals.@"close-request".impl.emit(
|
|
self,
|
|
null,
|
|
.{process_active},
|
|
null,
|
|
);
|
|
}
|
|
|
|
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 {
|
|
return self.private().size;
|
|
}
|
|
|
|
pub fn getCursorPos(self: *Self) apprt.CursorPos {
|
|
return self.private().cursor_pos;
|
|
}
|
|
|
|
pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap {
|
|
_ = self;
|
|
|
|
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");
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Virtual Methods
|
|
|
|
fn init(self: *Self, _: *Class) callconv(.C) void {
|
|
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
|
|
|
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 = .{
|
|
// Funky numbers on purpose so they stand out if for some reason
|
|
// our size doesn't get properly set.
|
|
.width = 111,
|
|
.height = 111,
|
|
};
|
|
|
|
// 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();
|
|
}
|
|
|
|
const self_widget = self.as(gtk.Widget);
|
|
|
|
// Setup our event controllers to get input events
|
|
const ec_key = gtk.EventControllerKey.new();
|
|
errdefer ec_key.unref();
|
|
self_widget.addController(ec_key.as(gtk.EventController));
|
|
errdefer self_widget.removeController(ec_key.as(gtk.EventController));
|
|
_ = gtk.EventControllerKey.signals.key_pressed.connect(
|
|
ec_key,
|
|
*Self,
|
|
ecKeyPressed,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerKey.signals.key_released.connect(
|
|
ec_key,
|
|
*Self,
|
|
ecKeyReleased,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Focus controller will tell us about focus enter/exit events
|
|
const ec_focus = gtk.EventControllerFocus.new();
|
|
errdefer ec_focus.unref();
|
|
self_widget.addController(ec_focus.as(gtk.EventController));
|
|
errdefer self_widget.removeController(ec_focus.as(gtk.EventController));
|
|
_ = gtk.EventControllerFocus.signals.enter.connect(
|
|
ec_focus,
|
|
*Self,
|
|
ecFocusEnter,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerFocus.signals.leave.connect(
|
|
ec_focus,
|
|
*Self,
|
|
ecFocusLeave,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Clicks
|
|
const gesture_click = gtk.GestureClick.new();
|
|
errdefer gesture_click.unref();
|
|
gesture_click.as(gtk.GestureSingle).setButton(0);
|
|
self_widget.addController(gesture_click.as(gtk.EventController));
|
|
errdefer self_widget.removeController(gesture_click.as(gtk.EventController));
|
|
_ = gtk.GestureClick.signals.pressed.connect(
|
|
gesture_click,
|
|
*Self,
|
|
gcMouseDown,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GestureClick.signals.released.connect(
|
|
gesture_click,
|
|
*Self,
|
|
gcMouseUp,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Mouse movement
|
|
const ec_motion = gtk.EventControllerMotion.new();
|
|
errdefer ec_motion.unref();
|
|
self_widget.addController(ec_motion.as(gtk.EventController));
|
|
errdefer self_widget.removeController(ec_motion.as(gtk.EventController));
|
|
_ = gtk.EventControllerMotion.signals.motion.connect(
|
|
ec_motion,
|
|
*Self,
|
|
ecMouseMotion,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerMotion.signals.leave.connect(
|
|
ec_motion,
|
|
*Self,
|
|
ecMouseLeave,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Scroll
|
|
const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes);
|
|
errdefer ec_scroll.unref();
|
|
self_widget.addController(ec_scroll.as(gtk.EventController));
|
|
errdefer self_widget.removeController(ec_scroll.as(gtk.EventController));
|
|
_ = gtk.EventControllerScroll.signals.scroll.connect(
|
|
ec_scroll,
|
|
*Self,
|
|
ecMouseScroll,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerScroll.signals.scroll_begin.connect(
|
|
ec_scroll,
|
|
*Self,
|
|
ecMouseScrollPrecisionBegin,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerScroll.signals.scroll_end.connect(
|
|
ec_scroll,
|
|
*Self,
|
|
ecMouseScrollPrecisionEnd,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Setup our input method state
|
|
const im_context = gtk.IMMulticontext.new();
|
|
priv.im_context = im_context;
|
|
priv.in_keyevent = .false;
|
|
priv.im_composing = false;
|
|
priv.im_len = 0;
|
|
_ = gtk.IMContext.signals.preedit_start.connect(
|
|
im_context,
|
|
*Self,
|
|
imPreeditStart,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.preedit_changed.connect(
|
|
im_context,
|
|
*Self,
|
|
imPreeditChanged,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.preedit_end.connect(
|
|
im_context,
|
|
*Self,
|
|
imPreeditEnd,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.commit.connect(
|
|
im_context,
|
|
*Self,
|
|
imCommit,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Initialize our GLArea. We could do a lot of this in
|
|
// the Blueprint file but I think its cleaner to separate
|
|
// the "UI" part of the blueprint file from the internal logic/config
|
|
// part.
|
|
const gl_area = priv.gl_area;
|
|
gl_area.setRequiredVersion(
|
|
renderer.OpenGL.MIN_VERSION_MAJOR,
|
|
renderer.OpenGL.MIN_VERSION_MINOR,
|
|
);
|
|
gl_area.setHasStencilBuffer(0);
|
|
gl_area.setHasDepthBuffer(0);
|
|
gl_area.setUseEs(0);
|
|
gl_area.as(gtk.Widget).setCursorFromName("text");
|
|
_ = gtk.Widget.signals.realize.connect(
|
|
gl_area,
|
|
*Self,
|
|
glareaRealize,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.Widget.signals.unrealize.connect(
|
|
gl_area,
|
|
*Self,
|
|
glareaUnrealize,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GLArea.signals.render.connect(
|
|
gl_area,
|
|
*Self,
|
|
glareaRender,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GLArea.signals.resize.connect(
|
|
gl_area,
|
|
*Self,
|
|
glareaResize,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Some property signals
|
|
_ = gobject.Object.signals.notify.connect(
|
|
self,
|
|
?*anyopaque,
|
|
&propConfig,
|
|
null,
|
|
.{ .detail = "config" },
|
|
);
|
|
_ = gobject.Object.signals.notify.connect(
|
|
self,
|
|
?*anyopaque,
|
|
&propMouseHoverUrl,
|
|
null,
|
|
.{ .detail = "mouse-hover-url" },
|
|
);
|
|
_ = gobject.Object.signals.notify.connect(
|
|
self,
|
|
?*anyopaque,
|
|
&propMouseHidden,
|
|
null,
|
|
.{ .detail = "mouse-hidden" },
|
|
);
|
|
_ = gobject.Object.signals.notify.connect(
|
|
self,
|
|
?*anyopaque,
|
|
&propMouseShape,
|
|
null,
|
|
.{ .detail = "mouse-shape" },
|
|
);
|
|
|
|
// Some other initialization steps
|
|
self.initUrlOverlay();
|
|
self.initResizeOverlay();
|
|
|
|
// Initialize our config
|
|
self.propConfig(undefined, null);
|
|
}
|
|
|
|
fn initResizeOverlay(self: *Self) void {
|
|
const priv = self.private();
|
|
const overlay = priv.overlay;
|
|
overlay.addOverlay(priv.resize_overlay.as(gtk.Widget));
|
|
}
|
|
|
|
fn initUrlOverlay(self: *Self) void {
|
|
const priv = self.private();
|
|
const overlay = priv.overlay;
|
|
const url_left = priv.url_left.as(gtk.Widget);
|
|
const url_right = priv.url_right.as(gtk.Widget);
|
|
|
|
// Add the url label to the overlay
|
|
overlay.addOverlay(url_left);
|
|
overlay.addOverlay(url_right);
|
|
|
|
// Setup a motion controller to handle moving the label
|
|
// to avoid the mouse.
|
|
const ec_motion = gtk.EventControllerMotion.new();
|
|
errdefer ec_motion.unref();
|
|
url_left.addController(ec_motion.as(gtk.EventController));
|
|
errdefer url_left.removeController(ec_motion.as(gtk.EventController));
|
|
_ = gtk.EventControllerMotion.signals.enter.connect(
|
|
ec_motion,
|
|
*Self,
|
|
ecUrlMouseEnter,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerMotion.signals.leave.connect(
|
|
ec_motion,
|
|
*Self,
|
|
ecUrlMouseLeave,
|
|
self,
|
|
.{},
|
|
);
|
|
}
|
|
|
|
fn dispose(self: *Self) callconv(.C) void {
|
|
const priv = self.private();
|
|
if (priv.config) |v| {
|
|
v.unref();
|
|
priv.config = null;
|
|
}
|
|
if (priv.im_context) |v| {
|
|
v.unref();
|
|
priv.im_context = 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());
|
|
|
|
// 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.pwd) |v| {
|
|
glib.free(@constCast(@ptrCast(v)));
|
|
priv.pwd = null;
|
|
}
|
|
if (priv.title) |v| {
|
|
glib.free(@constCast(@ptrCast(v)));
|
|
priv.title = 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;
|
|
}
|
|
|
|
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 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) {
|
|
priv.gl_area.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.
|
|
priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Signal Handlers
|
|
|
|
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;
|
|
|
|
if (priv.im_context) |im_context| {
|
|
im_context.as(gtk.IMContext).focusIn();
|
|
}
|
|
|
|
_ = glib.idleAddOnce(idleFocus, self.ref());
|
|
}
|
|
|
|
fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
priv.focused = false;
|
|
|
|
if (priv.im_context) |im_context| {
|
|
im_context.as(gtk.IMContext).focusOut();
|
|
}
|
|
|
|
_ = glib.idleAddOnce(idleFocus, self.ref());
|
|
}
|
|
|
|
/// The focus callback must be triggerd 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;
|
|
|
|
// If we don't have focus, grab it.
|
|
const priv = self.private();
|
|
const gl_area_widget = priv.gl_area.as(gtk.Widget);
|
|
if (gl_area_widget.hasFocus() == 0) {
|
|
_ = gl_area_widget.grabFocus();
|
|
}
|
|
|
|
// Report the event
|
|
const consumed = if (priv.core_surface) |surface| consumed: {
|
|
const gtk_mods = event.getModifierState();
|
|
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
break :consumed surface.mouseButtonCallback(
|
|
.press,
|
|
button,
|
|
mods,
|
|
) catch |err| err: {
|
|
log.warn("error in key callback err={}", .{err});
|
|
break :err false;
|
|
};
|
|
} else false;
|
|
|
|
// TODO: context menu
|
|
_ = consumed;
|
|
_ = x;
|
|
_ = y;
|
|
}
|
|
|
|
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();
|
|
if (priv.core_surface) |surface| {
|
|
const gtk_mods = event.getModifierState();
|
|
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
_ = surface.mouseButtonCallback(
|
|
.release,
|
|
button,
|
|
mods,
|
|
) catch |err| {
|
|
log.warn("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (priv.core_surface) |surface| {
|
|
// 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;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
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", .{});
|
|
|
|
// Setup our core surface
|
|
self.realizeSurface() catch |err| {
|
|
log.warn("surface failed to realize err={}", .{err});
|
|
};
|
|
|
|
// 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.
|
|
if (self.private().im_context) |im_context| {
|
|
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
|
|
if (priv.im_context) |im_context| {
|
|
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();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
const RealizeError = Allocator.Error || error{
|
|
GLAreaError,
|
|
RendererError,
|
|
SurfaceError,
|
|
};
|
|
|
|
fn realizeSurface(self: *Self) RealizeError!void {
|
|
const priv = self.private();
|
|
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;
|
|
}
|
|
|
|
// If we already have an initialized surface then we just notify.
|
|
if (priv.core_surface) |v| {
|
|
v.renderer.displayRealized() catch |err| {
|
|
log.warn("core displayRealized failed err={}", .{err});
|
|
return error.RendererError;
|
|
};
|
|
self.redraw();
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
// 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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
const C = Common(Self, Private);
|
|
pub const as = C.as;
|
|
pub const ref = C.ref;
|
|
pub const unref = C.unref;
|
|
const private = C.private;
|
|
|
|
pub const Class = extern struct {
|
|
parent_class: Parent.Class,
|
|
var parent: *Parent.Class = undefined;
|
|
pub const Instance = Self;
|
|
|
|
fn init(class: *Class) callconv(.C) void {
|
|
gobject.ext.ensureType(ResizeOverlay);
|
|
gtk.Widget.Class.setTemplateFromResource(
|
|
class.as(gtk.Widget.Class),
|
|
comptime gresource.blueprint(.{
|
|
.major = 1,
|
|
.minor = 2,
|
|
.name = "surface",
|
|
}),
|
|
);
|
|
|
|
// Bindings
|
|
class.bindTemplateChildPrivate("overlay", .{});
|
|
class.bindTemplateChildPrivate("gl_area", .{});
|
|
class.bindTemplateChildPrivate("url_left", .{});
|
|
class.bindTemplateChildPrivate("url_right", .{});
|
|
class.bindTemplateChildPrivate("resize_overlay", .{});
|
|
|
|
// Properties
|
|
gobject.ext.registerProperties(class, &.{
|
|
properties.config.impl,
|
|
properties.focused.impl,
|
|
properties.@"mouse-shape".impl,
|
|
properties.@"mouse-hidden".impl,
|
|
properties.@"mouse-hover-url".impl,
|
|
properties.pwd.impl,
|
|
properties.title.impl,
|
|
});
|
|
|
|
// Signals
|
|
signals.@"close-request".impl.register(.{});
|
|
|
|
// Virtual methods
|
|
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
|
|
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
|
|
}
|
|
|
|
pub const as = C.Class.as;
|
|
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
|
|
};
|
|
};
|
|
|
|
/// 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 {
|
|
/// Get the specific type of clipboard for a widget.
|
|
pub fn get(
|
|
widget: *gtk.Widget,
|
|
clipboard: apprt.Clipboard,
|
|
) ?*gdk.Clipboard {
|
|
return switch (clipboard) {
|
|
.standard => widget.getClipboard(),
|
|
.selection, .primary => widget.getPrimaryClipboard(),
|
|
};
|
|
}
|
|
|
|
/// 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 and false) {
|
|
const clipboard = get(
|
|
priv.gl_area.as(gtk.Widget),
|
|
clipboard_type,
|
|
) orelse return;
|
|
clipboard.setText(val);
|
|
return;
|
|
}
|
|
|
|
// Build a text buffer for our contents
|
|
const contents_buf: *gtk.TextBuffer = .new(null);
|
|
defer contents_buf.unref();
|
|
contents_buf.insertAtCursor(val, @intCast(val.len));
|
|
|
|
// Confirm
|
|
const diag = gobject.ext.newInstance(
|
|
ClipboardConfirmationDialog,
|
|
.{
|
|
.request = &apprt.ClipboardRequest{ .osc_52_write = clipboard_type },
|
|
.@"can-remember" = true,
|
|
.@"clipboard-contents" = contents_buf,
|
|
},
|
|
);
|
|
diag.present(self.as(gtk.Widget));
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
=> {
|
|
log.warn("unsafe paste, TODO confirmation", .{});
|
|
|
|
// Create a dialog and ask the user if they want to paste anyway.
|
|
// ClipboardConfirmationWindow.create(
|
|
// self.app,
|
|
// str,
|
|
// &self.core_surface,
|
|
// req.state,
|
|
// self.is_secure_input,
|
|
// ) catch |window_err| {
|
|
// log.warn(
|
|
// "failed to create clipboard confirmation window err={}",
|
|
// .{window_err},
|
|
// );
|
|
// };
|
|
return;
|
|
},
|
|
|
|
else => log.warn(
|
|
"failed to complete clipboard request err={}",
|
|
.{err},
|
|
),
|
|
};
|
|
}
|
|
|
|
/// 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,
|
|
};
|
|
};
|