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" ("안녕"). 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, }; };