mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-24 12:46:10 +03:00
2534 lines
84 KiB
Zig
2534 lines
84 KiB
Zig
/// A surface represents one drawable terminal surface. The surface may be
|
|
/// attached to a window or it may be some other kind of surface. This struct
|
|
/// is meant to be generic to all scenarios.
|
|
const Surface = @This();
|
|
|
|
const std = @import("std");
|
|
|
|
const adw = @import("adw");
|
|
const gtk = @import("gtk");
|
|
const gdk = @import("gdk");
|
|
const glib = @import("glib");
|
|
const gio = @import("gio");
|
|
const gobject = @import("gobject");
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
const build_config = @import("../../build_config.zig");
|
|
const build_options = @import("build_options");
|
|
const configpkg = @import("../../config.zig");
|
|
const apprt = @import("../../apprt.zig");
|
|
const font = @import("../../font/main.zig");
|
|
const i18n = @import("../../os/main.zig").i18n;
|
|
const input = @import("../../input.zig");
|
|
const renderer = @import("../../renderer.zig");
|
|
const terminal = @import("../../terminal/main.zig");
|
|
const CoreSurface = @import("../../Surface.zig");
|
|
const internal_os = @import("../../os/main.zig");
|
|
|
|
const App = @import("App.zig");
|
|
const Split = @import("Split.zig");
|
|
const Tab = @import("Tab.zig");
|
|
const Window = @import("Window.zig");
|
|
const Menu = @import("menu.zig").Menu;
|
|
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
|
|
const ResizeOverlay = @import("ResizeOverlay.zig");
|
|
const URLWidget = @import("URLWidget.zig");
|
|
const CloseDialog = @import("CloseDialog.zig");
|
|
const inspectorpkg = @import("inspector.zig");
|
|
const gtk_key = @import("key.zig");
|
|
const Builder = @import("Builder.zig");
|
|
const adw_version = @import("adw_version.zig");
|
|
|
|
const log = std.log.scoped(.gtk_surface);
|
|
|
|
pub const Options = struct {
|
|
/// The parent surface to inherit settings such as font size, working
|
|
/// directory, etc. from.
|
|
parent: ?*CoreSurface = null,
|
|
};
|
|
|
|
/// The container that this surface is directly attached to.
|
|
pub const Container = union(enum) {
|
|
/// The surface is not currently attached to anything. This means
|
|
/// that the GLArea has been created and potentially initialized
|
|
/// but the widget is currently floating and not part of any parent.
|
|
none: void,
|
|
|
|
/// Directly attached to a tab. (i.e. no splits)
|
|
tab_: *Tab,
|
|
|
|
/// A split within a split hierarchy. The key determines the
|
|
/// position of the split within the parent split.
|
|
split_tl: *Elem,
|
|
split_br: *Elem,
|
|
|
|
/// The side of the split.
|
|
pub const SplitSide = enum { top_left, bottom_right };
|
|
|
|
/// Elem is the possible element of any container. A container can
|
|
/// hold both a surface and a split. Any valid container should
|
|
/// have an Elem value so that it can be properly used with
|
|
/// splits.
|
|
pub const Elem = union(enum) {
|
|
/// A surface is a leaf element of the split -- a terminal
|
|
/// surface.
|
|
surface: *Surface,
|
|
|
|
/// A split is a nested split within a split. This lets you
|
|
/// for example have a horizontal split with a vertical split
|
|
/// on the left side (amongst all other possible
|
|
/// combinations).
|
|
split: *Split,
|
|
|
|
/// Returns the GTK widget to add to the paned for the given
|
|
/// element
|
|
pub fn widget(self: Elem) *gtk.Widget {
|
|
return switch (self) {
|
|
.surface => |s| s.primaryWidget(),
|
|
.split => |s| s.paned.as(gtk.Widget),
|
|
};
|
|
}
|
|
|
|
pub fn containerPtr(self: Elem) *Container {
|
|
return switch (self) {
|
|
.surface => |s| &s.container,
|
|
.split => |s| &s.container,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: Elem, alloc: Allocator) void {
|
|
switch (self) {
|
|
.surface => |s| s.unref(),
|
|
.split => |s| s.destroy(alloc),
|
|
}
|
|
}
|
|
|
|
pub fn grabFocus(self: Elem) void {
|
|
switch (self) {
|
|
.surface => |s| s.grabFocus(),
|
|
.split => |s| s.grabFocus(),
|
|
}
|
|
}
|
|
|
|
pub fn equalize(self: Elem) f64 {
|
|
return switch (self) {
|
|
.surface => 1,
|
|
.split => |s| s.equalize(),
|
|
};
|
|
}
|
|
|
|
/// The last surface in this container in the direction specified.
|
|
/// Direction must be "top_left" or "bottom_right".
|
|
pub fn deepestSurface(self: Elem, side: SplitSide) ?*Surface {
|
|
return switch (self) {
|
|
.surface => |s| s,
|
|
.split => |s| (switch (side) {
|
|
.top_left => s.top_left,
|
|
.bottom_right => s.bottom_right,
|
|
}).deepestSurface(side),
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Returns the window that this surface is attached to.
|
|
pub fn window(self: Container) ?*Window {
|
|
return switch (self) {
|
|
.none => null,
|
|
.tab_ => |v| v.window,
|
|
.split_tl, .split_br => split: {
|
|
const s = self.split() orelse break :split null;
|
|
break :split s.container.window();
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Returns the tab container if it exists.
|
|
pub fn tab(self: Container) ?*Tab {
|
|
return switch (self) {
|
|
.none => null,
|
|
.tab_ => |v| v,
|
|
.split_tl, .split_br => split: {
|
|
const s = self.split() orelse break :split null;
|
|
break :split s.container.tab();
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Returns the split containing this surface (if any).
|
|
pub fn split(self: Container) ?*Split {
|
|
return switch (self) {
|
|
.none, .tab_ => null,
|
|
.split_tl => |ptr| @fieldParentPtr("top_left", ptr),
|
|
.split_br => |ptr| @fieldParentPtr("bottom_right", ptr),
|
|
};
|
|
}
|
|
|
|
/// The side that we are in the split.
|
|
pub fn splitSide(self: Container) ?SplitSide {
|
|
return switch (self) {
|
|
.none, .tab_ => null,
|
|
.split_tl => .top_left,
|
|
.split_br => .bottom_right,
|
|
};
|
|
}
|
|
|
|
/// Returns the first split with the given orientation, walking upwards in
|
|
/// the tree.
|
|
pub fn firstSplitWithOrientation(
|
|
self: Container,
|
|
orientation: Split.Orientation,
|
|
) ?*Split {
|
|
return switch (self) {
|
|
.none, .tab_ => null,
|
|
.split_tl, .split_br => split: {
|
|
const s = self.split() orelse break :split null;
|
|
if (s.orientation == orientation) break :split s;
|
|
break :split s.container.firstSplitWithOrientation(orientation);
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Replace the container's element with this element. This is
|
|
/// used by children to modify their parents to for example change
|
|
/// from a surface to a split or a split back to a surface or
|
|
/// a split to a nested split and so on.
|
|
pub fn replace(self: Container, elem: Elem) void {
|
|
// Move the element into the container
|
|
switch (self) {
|
|
.none => {},
|
|
.tab_ => |t| t.replaceElem(elem),
|
|
inline .split_tl, .split_br => |ptr| {
|
|
const s = self.split().?;
|
|
s.replace(ptr, elem);
|
|
},
|
|
}
|
|
|
|
// Update the reverse reference to the container
|
|
elem.containerPtr().* = self;
|
|
}
|
|
|
|
/// Remove ourselves from the container. This is used by
|
|
/// children to effectively notify they're container that
|
|
/// all children at this level are exiting.
|
|
pub fn remove(self: Container) void {
|
|
switch (self) {
|
|
.none => {},
|
|
.tab_ => |t| t.remove(),
|
|
.split_tl => self.split().?.removeTopLeft(),
|
|
.split_br => self.split().?.removeBottomRight(),
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Whether the surface has been realized or not yet. When a surface is
|
|
/// "realized" it means that the OpenGL context is ready and the core
|
|
/// surface has been initialized.
|
|
realized: bool = false,
|
|
|
|
/// The config to use to initialize a surface.
|
|
init_config: InitConfig,
|
|
|
|
/// The GUI container that this surface has been attached to. This
|
|
/// dictates some behaviors such as new splits, etc.
|
|
container: Container = .{ .none = {} },
|
|
|
|
/// The app we're part of
|
|
app: *App,
|
|
|
|
/// The overlay, this is the primary widget
|
|
overlay: *gtk.Overlay,
|
|
|
|
/// Our GTK area
|
|
gl_area: *gtk.GLArea,
|
|
|
|
/// If non-null this is the widget on the overlay that shows the URL.
|
|
url_widget: ?URLWidget = null,
|
|
|
|
/// The overlay that shows resizing information.
|
|
resize_overlay: ResizeOverlay = undefined,
|
|
|
|
/// Whether or not the current surface is zoomed in (see `toggle_split_zoom`).
|
|
zoomed_in: bool = false,
|
|
|
|
/// If non-null this is the widget on the overlay which dims the surface when it is unfocused
|
|
unfocused_widget: ?*gtk.Widget = null,
|
|
|
|
/// Any active cursor we may have
|
|
cursor: ?*gdk.Cursor = null,
|
|
|
|
/// Our title. The raw value of the title. This will be kept up to date and
|
|
/// .title will be updated if we have focus.
|
|
/// When set the text in this buf will be null-terminated, because we need to
|
|
/// pass it to GTK.
|
|
title_text: ?[:0]const u8 = null,
|
|
|
|
/// The title of the surface as reported by the terminal. If it is null, the
|
|
/// title reported by the terminal is currently being used. If the title was
|
|
/// manually overridden by the user, this will be set to a non-null value
|
|
/// representing the default terminal title.
|
|
title_from_terminal: ?[:0]const u8 = null,
|
|
|
|
/// Our current working directory. We use this value for setting tooltips in
|
|
/// the headerbar subtitle if we have focus. When set, the text in this buf
|
|
/// will be null-terminated because we need to pass it to GTK.
|
|
pwd: ?[:0]const u8 = null,
|
|
|
|
/// The timer used to delay title updates in order to prevent flickering.
|
|
update_title_timer: ?c_uint = null,
|
|
|
|
/// The core surface backing this surface
|
|
core_surface: CoreSurface,
|
|
|
|
/// The font size to use for this surface once realized.
|
|
font_size: ?font.face.DesiredSize = null,
|
|
|
|
/// Cached metrics about the surface from GTK callbacks.
|
|
size: apprt.SurfaceSize,
|
|
cursor_pos: apprt.CursorPos,
|
|
|
|
/// Inspector state.
|
|
inspector: ?*inspectorpkg.Inspector = null,
|
|
|
|
/// Key input states. See gtkKeyPressed for detailed descriptions.
|
|
in_keyevent: IMKeyEvent = .false,
|
|
im_context: *gtk.IMMulticontext,
|
|
im_composing: bool = false,
|
|
im_buf: [128]u8 = undefined,
|
|
im_len: u7 = 0,
|
|
|
|
/// The surface-specific cgroup path. See App.transient_cgroup_path for
|
|
/// details on what this is.
|
|
cgroup_path: ?[]const u8 = null,
|
|
|
|
/// Our context menu.
|
|
context_menu: Menu(Surface, "context_menu", false),
|
|
|
|
/// True when we have a precision scroll in progress
|
|
precision_scroll: bool = false,
|
|
|
|
/// Flag indicating whether the surface is in secure input mode.
|
|
is_secure_input: bool = false,
|
|
|
|
/// 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,
|
|
};
|
|
|
|
/// Configuration used for initializing the surface. We have to copy some
|
|
/// data since initialization is delayed with GTK (on realize).
|
|
pub const InitConfig = struct {
|
|
parent: bool = false,
|
|
pwd: ?[]const u8 = null,
|
|
|
|
pub fn init(
|
|
alloc: Allocator,
|
|
app: *App,
|
|
opts: Options,
|
|
) Allocator.Error!InitConfig {
|
|
const parent = opts.parent orelse return .{};
|
|
|
|
const pwd: ?[]const u8 = if (app.config.@"window-inherit-working-directory")
|
|
try parent.pwd(alloc)
|
|
else
|
|
null;
|
|
errdefer if (pwd) |p| alloc.free(p);
|
|
|
|
return .{
|
|
.parent = true,
|
|
.pwd = pwd,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *InitConfig, alloc: Allocator) void {
|
|
if (self.pwd) |pwd| alloc.free(pwd);
|
|
}
|
|
};
|
|
|
|
pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface {
|
|
var surface = try alloc.create(Surface);
|
|
errdefer alloc.destroy(surface);
|
|
try surface.init(app, opts);
|
|
return surface;
|
|
}
|
|
|
|
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
|
const gl_area = gtk.GLArea.new();
|
|
const gl_area_widget = gl_area.as(gtk.Widget);
|
|
|
|
// Create an overlay so we can layer the GL area with other widgets.
|
|
const overlay = gtk.Overlay.new();
|
|
errdefer overlay.unref();
|
|
const overlay_widget = overlay.as(gtk.Widget);
|
|
overlay.setChild(gl_area_widget);
|
|
|
|
// Overlay is not focusable, but the GL area is.
|
|
overlay_widget.setFocusable(0);
|
|
overlay_widget.setFocusOnClick(0);
|
|
|
|
// We grab the floating reference to the primary widget. This allows the
|
|
// widget tree to be moved around i.e. between a split, a tab, etc.
|
|
// without having to be really careful about ordering to
|
|
// prevent a destroy.
|
|
//
|
|
// This is unref'd in the unref() method that's called by the
|
|
// self.container through Elem.deinit.
|
|
_ = overlay.as(gobject.Object).refSink();
|
|
errdefer overlay.unref();
|
|
|
|
// We want the gl area to expand to fill the parent container.
|
|
gl_area_widget.setHexpand(1);
|
|
gl_area_widget.setVexpand(1);
|
|
|
|
// Various other GL properties
|
|
gl_area_widget.setCursorFromName("text");
|
|
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);
|
|
|
|
// Key event controller will tell us about raw keypress events.
|
|
const ec_key = gtk.EventControllerKey.new();
|
|
errdefer ec_key.unref();
|
|
overlay_widget.addController(ec_key.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(ec_key.as(gtk.EventController));
|
|
|
|
// Focus controller will tell us about focus enter/exit events
|
|
const ec_focus = gtk.EventControllerFocus.new();
|
|
errdefer ec_focus.unref();
|
|
overlay_widget.addController(ec_focus.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(ec_focus.as(gtk.EventController));
|
|
|
|
// Create a second key controller so we can receive the raw
|
|
// key-press events BEFORE the input method gets them.
|
|
const ec_key_press = gtk.EventControllerKey.new();
|
|
errdefer ec_key_press.unref();
|
|
overlay_widget.addController(ec_key_press.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(ec_key_press.as(gtk.EventController));
|
|
|
|
// Clicks
|
|
const gesture_click = gtk.GestureClick.new();
|
|
errdefer gesture_click.unref();
|
|
gesture_click.as(gtk.GestureSingle).setButton(0);
|
|
overlay_widget.addController(gesture_click.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(gesture_click.as(gtk.EventController));
|
|
|
|
// Mouse movement
|
|
const ec_motion = gtk.EventControllerMotion.new();
|
|
errdefer ec_motion.unref();
|
|
overlay_widget.addController(ec_motion.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(ec_motion.as(gtk.EventController));
|
|
|
|
// Scroll events
|
|
const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes);
|
|
errdefer ec_scroll.unref();
|
|
overlay_widget.addController(ec_scroll.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(ec_scroll.as(gtk.EventController));
|
|
|
|
// The input method context that we use to translate key events into
|
|
// characters. This doesn't have an event key controller attached because
|
|
// we call it manually from our own key controller.
|
|
const im_context = gtk.IMMulticontext.new();
|
|
errdefer im_context.unref();
|
|
|
|
// The GL area has to be focusable so that it can receive events
|
|
gl_area_widget.setFocusable(1);
|
|
gl_area_widget.setFocusOnClick(1);
|
|
|
|
// Set up to handle items being dropped on our surface. Files can be dropped
|
|
// from Nautilus and strings can be dropped from many programs.
|
|
const drop_target = gtk.DropTarget.new(gobject.ext.types.invalid, .flags_copy);
|
|
errdefer drop_target.unref();
|
|
// The order of the types matters.
|
|
var drop_target_types = [_]gobject.Type{
|
|
gdk.FileList.getGObjectType(),
|
|
gio.File.getGObjectType(),
|
|
gobject.ext.types.string,
|
|
};
|
|
drop_target.setGtypes(&drop_target_types, drop_target_types.len);
|
|
overlay_widget.addController(drop_target.as(gtk.EventController));
|
|
errdefer overlay_widget.removeController(drop_target.as(gtk.EventController));
|
|
|
|
// Inherit the parent's font size if we have a parent.
|
|
const font_size: ?font.face.DesiredSize = font_size: {
|
|
if (!app.config.@"window-inherit-font-size") break :font_size null;
|
|
const parent = opts.parent orelse break :font_size null;
|
|
break :font_size parent.font_size;
|
|
};
|
|
|
|
// If the parent has a transient cgroup, then we're creating cgroups
|
|
// for each surface if we can. We need to create a child cgroup.
|
|
const cgroup_path: ?[]const u8 = cgroup: {
|
|
const base = app.transient_cgroup_base orelse break :cgroup null;
|
|
|
|
// 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.err("failed to create surface cgroup err={}", .{err});
|
|
break :cgroup null;
|
|
};
|
|
|
|
// Success, save the cgroup path.
|
|
break :cgroup std.fmt.allocPrint(
|
|
app.core_app.alloc,
|
|
"{s}/{s}",
|
|
.{ base, name },
|
|
) catch null;
|
|
};
|
|
errdefer if (cgroup_path) |path| app.core_app.alloc.free(path);
|
|
|
|
// Build our initialization config
|
|
const init_config = try InitConfig.init(app.core_app.alloc, app, opts);
|
|
errdefer init_config.deinit(app.core_app.alloc);
|
|
|
|
// Build our result
|
|
self.* = .{
|
|
.app = app,
|
|
.container = .{ .none = {} },
|
|
.overlay = overlay,
|
|
.gl_area = gl_area,
|
|
.resize_overlay = undefined,
|
|
.title_text = null,
|
|
.core_surface = undefined,
|
|
.font_size = font_size,
|
|
.init_config = init_config,
|
|
.size = .{ .width = 800, .height = 600 },
|
|
.cursor_pos = .{ .x = -1, .y = -1 },
|
|
.im_context = im_context,
|
|
.cgroup_path = cgroup_path,
|
|
.context_menu = undefined,
|
|
};
|
|
errdefer self.* = undefined;
|
|
|
|
// initialize the context menu
|
|
self.context_menu.init(self);
|
|
self.context_menu.setParent(overlay.as(gtk.Widget));
|
|
|
|
// initialize the resize overlay
|
|
self.resize_overlay.init(self, &app.config);
|
|
|
|
// Set our default mouse shape
|
|
try self.setMouseShape(.text);
|
|
|
|
// GL events
|
|
_ = gtk.Widget.signals.realize.connect(
|
|
gl_area,
|
|
*Surface,
|
|
gtkRealize,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.Widget.signals.unrealize.connect(
|
|
gl_area,
|
|
*Surface,
|
|
gtkUnrealize,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.Widget.signals.destroy.connect(
|
|
gl_area,
|
|
*Surface,
|
|
gtkDestroy,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GLArea.signals.render.connect(
|
|
gl_area,
|
|
*Surface,
|
|
gtkRender,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GLArea.signals.resize.connect(
|
|
gl_area,
|
|
*Surface,
|
|
gtkResize,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerKey.signals.key_pressed.connect(
|
|
ec_key_press,
|
|
*Surface,
|
|
gtkKeyPressed,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerKey.signals.key_released.connect(
|
|
ec_key_press,
|
|
*Surface,
|
|
gtkKeyReleased,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerFocus.signals.enter.connect(
|
|
ec_focus,
|
|
*Surface,
|
|
gtkFocusEnter,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerFocus.signals.leave.connect(
|
|
ec_focus,
|
|
*Surface,
|
|
gtkFocusLeave,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GestureClick.signals.pressed.connect(
|
|
gesture_click,
|
|
*Surface,
|
|
gtkMouseDown,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.GestureClick.signals.released.connect(
|
|
gesture_click,
|
|
*Surface,
|
|
gtkMouseUp,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerMotion.signals.motion.connect(
|
|
ec_motion,
|
|
*Surface,
|
|
gtkMouseMotion,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerMotion.signals.leave.connect(
|
|
ec_motion,
|
|
*Surface,
|
|
gtkMouseLeave,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerScroll.signals.scroll.connect(
|
|
ec_scroll,
|
|
*Surface,
|
|
gtkMouseScroll,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerScroll.signals.scroll_begin.connect(
|
|
ec_scroll,
|
|
*Surface,
|
|
gtkMouseScrollPrecisionBegin,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.EventControllerScroll.signals.scroll_end.connect(
|
|
ec_scroll,
|
|
*Surface,
|
|
gtkMouseScrollPrecisionEnd,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.preedit_start.connect(
|
|
im_context,
|
|
*Surface,
|
|
gtkInputPreeditStart,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.preedit_changed.connect(
|
|
im_context,
|
|
*Surface,
|
|
gtkInputPreeditChanged,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.preedit_end.connect(
|
|
im_context,
|
|
*Surface,
|
|
gtkInputPreeditEnd,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.IMContext.signals.commit.connect(
|
|
im_context,
|
|
*Surface,
|
|
gtkInputCommit,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = gtk.DropTarget.signals.drop.connect(
|
|
drop_target,
|
|
*Surface,
|
|
gtkDrop,
|
|
self,
|
|
.{},
|
|
);
|
|
}
|
|
|
|
fn realize(self: *Surface) !void {
|
|
// If this surface has already been realized, then we don't need to
|
|
// reinitialize. This can happen if a surface is moved from one GDK
|
|
// surface to another (i.e. a tab is pulled out into a window).
|
|
if (self.realized) {
|
|
// If we have no OpenGL state though, we do need to reinitialize.
|
|
// We allow the renderer to figure that out, and then queue a draw.
|
|
try self.core_surface.renderer.displayRealized();
|
|
self.redraw();
|
|
return;
|
|
}
|
|
|
|
// Add ourselves to the list of surfaces on the app.
|
|
try self.app.core_app.addSurface(self);
|
|
errdefer self.app.core_app.deleteSurface(self);
|
|
|
|
// Get our new surface config
|
|
var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config);
|
|
defer config.deinit();
|
|
|
|
if (self.init_config.pwd) |pwd| {
|
|
// If we have a working directory we want, then we force that.
|
|
config.@"working-directory" = pwd;
|
|
} else if (!self.init_config.parent) {
|
|
// A hack, see the "parent_surface" field for more information.
|
|
config.@"working-directory" = self.app.config.@"working-directory";
|
|
}
|
|
|
|
// Initialize our surface now that we have the stable pointer.
|
|
try self.core_surface.init(
|
|
self.app.core_app.alloc,
|
|
&config,
|
|
self.app.core_app,
|
|
self.app,
|
|
self,
|
|
);
|
|
errdefer self.core_surface.deinit();
|
|
|
|
// If we have a font size we want, set that now
|
|
if (self.font_size) |size| {
|
|
try self.core_surface.setFontSize(size);
|
|
}
|
|
|
|
// Note we're realized
|
|
self.realized = true;
|
|
}
|
|
|
|
pub fn deinit(self: *Surface) void {
|
|
self.init_config.deinit(self.app.core_app.alloc);
|
|
if (self.title_text) |title| self.app.core_app.alloc.free(title);
|
|
if (self.title_from_terminal) |title| self.app.core_app.alloc.free(title);
|
|
if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd);
|
|
|
|
// We don't allocate anything if we aren't realized.
|
|
if (!self.realized) return;
|
|
|
|
// Delete our inspector if we have one
|
|
self.controlInspector(.hide);
|
|
|
|
// Remove ourselves from the list of known surfaces in the app.
|
|
self.app.core_app.deleteSurface(self);
|
|
|
|
// Clean up our core surface so that all the rendering and IO stop.
|
|
self.core_surface.deinit();
|
|
self.core_surface = undefined;
|
|
|
|
// Remove the cgroup if we have one. We do this after deiniting the core
|
|
// surface to ensure all processes have exited.
|
|
if (self.cgroup_path) |path| {
|
|
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 },
|
|
);
|
|
};
|
|
|
|
self.app.core_app.alloc.free(path);
|
|
}
|
|
|
|
// Free all our GTK stuff
|
|
//
|
|
// Note we don't do anything with the "unfocused_overlay" because
|
|
// it is attached to the overlay which by this point has been destroyed
|
|
// and therefore the unfocused_overlay has been destroyed as well.
|
|
self.im_context.unref();
|
|
if (self.cursor) |cursor| cursor.unref();
|
|
if (self.update_title_timer) |timer| _ = glib.Source.remove(timer);
|
|
self.resize_overlay.deinit();
|
|
}
|
|
|
|
/// Update our local copy of any configuration that we use.
|
|
pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void {
|
|
self.resize_overlay.updateConfig(config);
|
|
}
|
|
|
|
// unref removes the long-held reference to the gl_area and kicks off the
|
|
// deinit/destroy process for this surface.
|
|
pub fn unref(self: *Surface) void {
|
|
self.overlay.unref();
|
|
}
|
|
|
|
pub fn destroy(self: *Surface, alloc: Allocator) void {
|
|
self.deinit();
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
pub fn primaryWidget(self: *Surface) *gtk.Widget {
|
|
return self.overlay.as(gtk.Widget);
|
|
}
|
|
|
|
fn render(self: *Surface) !void {
|
|
try self.core_surface.renderer.drawFrame(true);
|
|
}
|
|
|
|
/// Called by core surface to get the cgroup.
|
|
pub fn cgroup(self: *const Surface) ?[]const u8 {
|
|
return self.cgroup_path;
|
|
}
|
|
|
|
/// Queue the inspector to render if we have one.
|
|
pub fn queueInspectorRender(self: *Surface) void {
|
|
if (self.inspector) |v| v.queueRender();
|
|
}
|
|
|
|
/// Invalidate the surface so that it forces a redraw on the next tick.
|
|
pub fn redraw(self: *Surface) void {
|
|
self.gl_area.queueRender();
|
|
}
|
|
|
|
/// Close this surface.
|
|
pub fn close(self: *Surface, process_active: bool) void {
|
|
self.closeWithConfirmation(process_active, .{ .surface = self });
|
|
}
|
|
|
|
/// Close this surface.
|
|
pub fn closeWithConfirmation(self: *Surface, process_active: bool, target: CloseDialog.Target) void {
|
|
self.setSplitZoom(false);
|
|
|
|
if (!process_active) {
|
|
self.container.remove();
|
|
return;
|
|
}
|
|
|
|
CloseDialog.show(target) catch |err| {
|
|
log.err("failed to open close dialog={}", .{err});
|
|
};
|
|
}
|
|
|
|
pub fn controlInspector(
|
|
self: *Surface,
|
|
mode: apprt.action.Inspector,
|
|
) void {
|
|
const show = switch (mode) {
|
|
.toggle => self.inspector == null,
|
|
.show => true,
|
|
.hide => false,
|
|
};
|
|
|
|
if (!show) {
|
|
if (self.inspector) |v| {
|
|
v.close();
|
|
self.inspector = null;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// If we already have an inspector, we don't need to show anything.
|
|
if (self.inspector != null) return;
|
|
self.inspector = inspectorpkg.Inspector.create(
|
|
self,
|
|
.{ .window = {} },
|
|
) catch |err| {
|
|
log.err("failed to control inspector err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
pub fn setShouldClose(self: *Surface) void {
|
|
_ = self;
|
|
}
|
|
|
|
pub fn shouldClose(self: *const Surface) bool {
|
|
_ = self;
|
|
return false;
|
|
}
|
|
|
|
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
|
const gtk_scale: f32 = scale: {
|
|
const widget = self.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: *const Surface) !apprt.SurfaceSize {
|
|
return self.size;
|
|
}
|
|
|
|
pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
|
|
// If we've already become realized once then we ignore this
|
|
// request. The apprt initial_size action should only modify
|
|
// the physical size of the window during initialization.
|
|
// Subsequent actions are only informative in case we want to
|
|
// implement a "return to default size" action later.
|
|
if (self.realized) return;
|
|
|
|
// If we are within a split, do not set the size.
|
|
if (self.container.split() != null) return;
|
|
|
|
// This operation only makes sense if we're within a window view
|
|
// hierarchy and we're the first tab in the window.
|
|
const window = self.container.window() orelse return;
|
|
if (window.notebook.nPages() > 1) return;
|
|
|
|
const gtk_window = window.window.as(gtk.Window);
|
|
|
|
// Note: this doesn't properly take into account the window decorations.
|
|
// I'm not currently sure how to do that.
|
|
gtk_window.setDefaultSize(@intCast(width), @intCast(height));
|
|
}
|
|
|
|
pub fn setSizeLimits(self: *const Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
|
|
|
|
// There's no support for setting max size at the moment.
|
|
_ = max_;
|
|
|
|
// If we are within a split, do not set the size.
|
|
if (self.container.split() != null) return;
|
|
|
|
// This operation only makes sense if we're within a window view
|
|
// hierarchy and we're the first tab in the window.
|
|
const window = self.container.window() orelse return;
|
|
if (window.notebook.nPages() > 1) return;
|
|
|
|
const widget = window.window.as(gtk.Widget);
|
|
|
|
// Note: this doesn't properly take into account the window decorations.
|
|
// I'm not currently sure how to do that.
|
|
widget.setSizeRequest(@intCast(min.width), @intCast(min.height));
|
|
}
|
|
|
|
pub fn grabFocus(self: *Surface) void {
|
|
if (self.container.tab()) |tab| {
|
|
// If any other surface was focused and zoomed in, set it to non zoomed in
|
|
// so that self can grab focus.
|
|
if (tab.focus_child) |focus_child| {
|
|
if (focus_child.zoomed_in and focus_child != self) {
|
|
focus_child.setSplitZoom(false);
|
|
}
|
|
}
|
|
tab.focus_child = self;
|
|
}
|
|
|
|
_ = self.gl_area.as(gtk.Widget).grabFocus();
|
|
|
|
self.updateTitleLabels();
|
|
}
|
|
|
|
fn updateTitleLabels(self: *Surface) void {
|
|
// If we have no title, then we have nothing to update.
|
|
const title = self.getTitle() orelse return;
|
|
|
|
// If we have a tab and are the focused child, then we have to update the tab
|
|
if (self.container.tab()) |tab| {
|
|
if (tab.focus_child == self) tab.setTitleText(title);
|
|
}
|
|
|
|
// If we have a window and are focused, then we have to update the window title.
|
|
if (self.container.window()) |window| {
|
|
const widget = self.gl_area.as(gtk.Widget);
|
|
if (widget.isFocus() != 0) {
|
|
// Changing the title somehow unhides our cursor.
|
|
// https://github.com/ghostty-org/ghostty/issues/1419
|
|
// I don't know a way around this yet. I've tried re-hiding the
|
|
// cursor after setting the title but it doesn't work, I think
|
|
// due to some gtk event loop things...
|
|
window.setTitle(title);
|
|
}
|
|
}
|
|
}
|
|
|
|
const zoom_title_prefix = "🔍 ";
|
|
pub const SetTitleSource = enum { user, terminal };
|
|
|
|
pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void {
|
|
const alloc = self.app.core_app.alloc;
|
|
|
|
// Always allocate with the "🔍 " at the beginning and slice accordingly
|
|
// is the surface is zoomed in or not.
|
|
const copy: [:0]const u8 = copy: {
|
|
const new_title = try alloc.allocSentinel(u8, zoom_title_prefix.len + slice.len, 0);
|
|
@memcpy(new_title[0..zoom_title_prefix.len], zoom_title_prefix);
|
|
@memcpy(new_title[zoom_title_prefix.len..], slice);
|
|
break :copy new_title;
|
|
};
|
|
errdefer alloc.free(copy);
|
|
|
|
// The user has overridden the title
|
|
// We only want to update the terminal provided title so that it can be restored to the most recent state.
|
|
if (self.title_from_terminal != null and source == .terminal) {
|
|
alloc.free(self.title_from_terminal.?);
|
|
self.title_from_terminal = copy;
|
|
return;
|
|
}
|
|
|
|
if (self.title_text) |old| alloc.free(old);
|
|
self.title_text = copy;
|
|
|
|
// delay the title update to prevent flickering
|
|
if (self.update_title_timer) |timer| {
|
|
if (glib.Source.remove(timer) == 0) {
|
|
log.warn("unable to remove update title timer", .{});
|
|
}
|
|
self.update_title_timer = null;
|
|
}
|
|
self.update_title_timer = glib.timeoutAdd(75, updateTitleTimerExpired, self);
|
|
}
|
|
|
|
fn updateTitleTimerExpired(ud: ?*anyopaque) callconv(.c) c_int {
|
|
const self: *Surface = @ptrCast(@alignCast(ud.?));
|
|
|
|
self.updateTitleLabels();
|
|
self.update_title_timer = null;
|
|
|
|
return 0;
|
|
}
|
|
|
|
pub fn getTitle(self: *Surface) ?[:0]const u8 {
|
|
if (self.title_text) |title_text| {
|
|
return self.resolveTitle(title_text);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
pub fn getTerminalTitle(self: *Surface) ?[:0]const u8 {
|
|
if (self.title_from_terminal) |title_text| {
|
|
return self.resolveTitle(title_text);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 {
|
|
return if (self.zoomed_in)
|
|
title
|
|
else
|
|
title[zoom_title_prefix.len..];
|
|
}
|
|
|
|
pub fn promptTitle(self: *Surface) !void {
|
|
if (!adw_version.atLeast(1, 5, 0)) return;
|
|
const window = self.container.window() orelse return;
|
|
|
|
var builder = Builder.init("prompt-title-dialog", 1, 5);
|
|
defer builder.deinit();
|
|
|
|
const entry = builder.getObject(gtk.Entry, "title_entry").?;
|
|
entry.getBuffer().setText(self.getTitle() orelse "", -1);
|
|
|
|
const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?;
|
|
dialog.choose(window.window.as(gtk.Widget), null, gtkPromptTitleResponse, self);
|
|
}
|
|
|
|
/// Set the current working directory of the surface.
|
|
///
|
|
/// In addition, update the tab's tooltip text, and if we are the focused child,
|
|
/// update the subtitle of the containing window.
|
|
pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void {
|
|
if (self.container.tab()) |tab| {
|
|
tab.setTooltipText(pwd);
|
|
|
|
if (tab.focus_child == self) {
|
|
if (self.container.window()) |window| {
|
|
if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd);
|
|
}
|
|
}
|
|
}
|
|
|
|
const alloc = self.app.core_app.alloc;
|
|
|
|
// Failing to set the surface's current working directory is not a big
|
|
// deal since we just used our slice parameter which is the same value.
|
|
if (self.pwd) |old| alloc.free(old);
|
|
self.pwd = alloc.dupeZ(u8, pwd) catch null;
|
|
}
|
|
|
|
pub fn setMouseShape(
|
|
self: *Surface,
|
|
shape: terminal.MouseShape,
|
|
) !void {
|
|
const name: [:0]const u8 = switch (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",
|
|
};
|
|
|
|
const cursor = gdk.Cursor.newFromName(name.ptr, null) orelse {
|
|
log.warn("unsupported cursor name={s}", .{name});
|
|
return;
|
|
};
|
|
errdefer cursor.unref();
|
|
|
|
// Set our new cursor. We only do this if the cursor we currently
|
|
// have is NOT set to "none" because setting the cursor causes it
|
|
// to become visible again.
|
|
const widget = self.gl_area.as(gtk.Widget);
|
|
if (widget.getCursor() != self.app.cursor_none) {
|
|
widget.setCursor(cursor);
|
|
}
|
|
|
|
// Free our existing cursor
|
|
if (self.cursor) |old| old.unref();
|
|
self.cursor = cursor;
|
|
}
|
|
|
|
/// Set the visibility of the mouse cursor.
|
|
pub fn setMouseVisibility(self: *Surface, visible: bool) void {
|
|
// Note in there that self.cursor or cursor_none may be null. That's
|
|
// not a problem because NULL is a valid argument for set cursor
|
|
// which means to just use the parent value.
|
|
const widget = self.gl_area.as(gtk.Widget);
|
|
|
|
if (visible) {
|
|
widget.setCursor(self.cursor);
|
|
return;
|
|
}
|
|
|
|
// Set our new cursor to the app "none" cursor
|
|
widget.setCursor(self.app.cursor_none);
|
|
}
|
|
|
|
pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void {
|
|
const uri = uri_ orelse {
|
|
if (self.url_widget) |*widget| {
|
|
widget.deinit(self.overlay);
|
|
self.url_widget = null;
|
|
}
|
|
|
|
return;
|
|
};
|
|
|
|
// We need a null-terminated string
|
|
const alloc = self.app.core_app.alloc;
|
|
const uriZ = alloc.dupeZ(u8, uri) catch return;
|
|
defer alloc.free(uriZ);
|
|
|
|
// If we have a URL widget already just change the text.
|
|
if (self.url_widget) |widget| {
|
|
widget.setText(uriZ);
|
|
return;
|
|
}
|
|
|
|
self.url_widget = .init(self.overlay, uriZ);
|
|
}
|
|
|
|
pub fn supportsClipboard(
|
|
self: *const Surface,
|
|
clipboard_type: apprt.Clipboard,
|
|
) bool {
|
|
_ = self;
|
|
return switch (clipboard_type) {
|
|
.standard,
|
|
.selection,
|
|
.primary,
|
|
=> true,
|
|
};
|
|
}
|
|
|
|
pub fn clipboardRequest(
|
|
self: *Surface,
|
|
clipboard_type: apprt.Clipboard,
|
|
state: apprt.ClipboardRequest,
|
|
) !void {
|
|
// We allocate for userdata for the clipboard request. Not ideal but
|
|
// clipboard requests aren't common so probably not a big deal.
|
|
const alloc = self.app.core_app.alloc;
|
|
const ud_ptr = try alloc.create(ClipboardRequest);
|
|
errdefer alloc.destroy(ud_ptr);
|
|
ud_ptr.* = .{ .self = self, .state = state };
|
|
|
|
// Start our async request
|
|
const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return;
|
|
|
|
clipboard.readTextAsync(null, gtkClipboardRead, ud_ptr);
|
|
}
|
|
|
|
pub fn setClipboardString(
|
|
self: *Surface,
|
|
val: [:0]const u8,
|
|
clipboard_type: apprt.Clipboard,
|
|
confirm: bool,
|
|
) !void {
|
|
if (!confirm) {
|
|
const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return;
|
|
clipboard.setText(val);
|
|
|
|
// We only toast if we are copying to the standard clipboard.
|
|
if (clipboard_type == .standard and
|
|
self.app.config.@"app-notifications".@"clipboard-copy")
|
|
{
|
|
if (self.container.window()) |window|
|
|
window.sendToast(i18n._("Copied to clipboard"));
|
|
}
|
|
return;
|
|
}
|
|
|
|
ClipboardConfirmationWindow.create(
|
|
self.app,
|
|
val,
|
|
&self.core_surface,
|
|
.{ .osc_52_write = clipboard_type },
|
|
self.is_secure_input,
|
|
) catch |window_err| {
|
|
log.err("failed to create clipboard confirmation window err={}", .{window_err});
|
|
};
|
|
}
|
|
|
|
const ClipboardRequest = struct {
|
|
self: *Surface,
|
|
state: apprt.ClipboardRequest,
|
|
};
|
|
|
|
fn gtkClipboardRead(
|
|
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: *ClipboardRequest = @ptrCast(@alignCast(ud orelse return));
|
|
const self = req.self;
|
|
const alloc = self.app.core_app.alloc;
|
|
defer alloc.destroy(req);
|
|
|
|
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);
|
|
|
|
self.core_surface.completeClipboardRequest(
|
|
req.state,
|
|
str,
|
|
false,
|
|
) catch |err| switch (err) {
|
|
error.UnsafePaste,
|
|
error.UnauthorizedPaste,
|
|
=> {
|
|
// 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.err("failed to create clipboard confirmation window err={}", .{window_err});
|
|
};
|
|
return;
|
|
},
|
|
|
|
else => log.err("failed to complete clipboard request err={}", .{err}),
|
|
};
|
|
}
|
|
|
|
fn getClipboard(widget: *gtk.Widget, clipboard: apprt.Clipboard) ?*gdk.Clipboard {
|
|
return switch (clipboard) {
|
|
.standard => widget.getClipboard(),
|
|
.selection, .primary => widget.getPrimaryClipboard(),
|
|
};
|
|
}
|
|
|
|
pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
|
|
return self.cursor_pos;
|
|
}
|
|
|
|
pub fn showDesktopNotification(
|
|
self: *Surface,
|
|
title: []const u8,
|
|
body: []const u8,
|
|
) !void {
|
|
// Set a default title if we don't already have one
|
|
const t = switch (title.len) {
|
|
0 => "Ghostty",
|
|
else => title,
|
|
};
|
|
|
|
const notification = gio.Notification.new(t);
|
|
defer notification.unref();
|
|
notification.setBody(body);
|
|
|
|
const icon = gio.ThemedIcon.new(build_config.bundle_id);
|
|
defer icon.unref();
|
|
|
|
notification.setIcon(icon);
|
|
|
|
const pointer = glib.Variant.newUint64(@intFromPtr(&self.core_surface));
|
|
notification.setDefaultActionAndTargetValue("app.present-surface", pointer);
|
|
|
|
const app = self.app.app.as(gio.Application);
|
|
|
|
// We set the notification ID to the body content. If the content is the
|
|
// same, this notification may replace a previous notification
|
|
app.sendNotification(body.ptr, notification);
|
|
}
|
|
|
|
fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void {
|
|
log.debug("gl surface realized", .{});
|
|
|
|
// We need to make the context current so we can call GL functions.
|
|
gl_area.makeCurrent();
|
|
if (gl_area.getError()) |err| {
|
|
log.err("surface failed to realize: {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;
|
|
}
|
|
|
|
// realize means that our OpenGL context is ready, so we can now
|
|
// initialize the core surface which will setup the renderer.
|
|
self.realize() catch |err| {
|
|
// TODO: we need to destroy the GL area here.
|
|
log.err("surface failed to realize: {}", .{err});
|
|
return;
|
|
};
|
|
|
|
// When we have a realized surface, we also attach our input method context.
|
|
// We do this here instead of init because this allows us to release the ref
|
|
// to the GLArea when we unrealized.
|
|
self.im_context.as(gtk.IMContext).setClientWidget(self.overlay.as(gtk.Widget));
|
|
}
|
|
|
|
/// This is called when the underlying OpenGL resources must be released.
|
|
/// This is usually due to the OpenGL area changing GDK surfaces.
|
|
fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void {
|
|
log.debug("gl surface unrealized", .{});
|
|
|
|
// See gtkRealize for why we do this here.
|
|
self.im_context.as(gtk.IMContext).setClientWidget(null);
|
|
|
|
// 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;
|
|
} else {
|
|
self.core_surface.renderer.displayUnrealized();
|
|
}
|
|
}
|
|
|
|
/// render signal
|
|
fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.c) c_int {
|
|
self.render() catch |err| {
|
|
log.err("surface failed to render: {}", .{err});
|
|
return 0;
|
|
};
|
|
|
|
return 1;
|
|
}
|
|
|
|
/// resize signal
|
|
fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface) callconv(.c) void {
|
|
// Some debug output to help understand what GTK is telling us.
|
|
{
|
|
const scale_factor = scale: {
|
|
const widget = gl_area.as(gtk.Widget);
|
|
break :scale widget.getScaleFactor();
|
|
};
|
|
|
|
const window_scale_factor = scale: {
|
|
const window = self.container.window() orelse break :scale 0;
|
|
const gtk_window = window.window.as(gtk.Window);
|
|
const gtk_native = gtk_window.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,
|
|
});
|
|
}
|
|
|
|
self.size = .{
|
|
.width = @intCast(width),
|
|
.height = @intCast(height),
|
|
};
|
|
|
|
// We also update the content scale because there is no signal for
|
|
// content scale change and it seems to trigger a resize event.
|
|
if (self.getContentScale()) |scale| {
|
|
self.core_surface.contentScaleCallback(scale) catch |err| {
|
|
log.err("error in content scale callback err={}", .{err});
|
|
return;
|
|
};
|
|
} else |_| {}
|
|
|
|
// Call the primary callback.
|
|
if (self.realized) {
|
|
self.core_surface.sizeCallback(self.size) catch |err| {
|
|
log.err("error in size callback err={}", .{err});
|
|
return;
|
|
};
|
|
|
|
if (self.container.window()) |window| {
|
|
window.winproto.resizeEvent() catch |err| {
|
|
log.warn("failed to notify window protocol of resize={}", .{err});
|
|
};
|
|
}
|
|
|
|
self.resize_overlay.maybeShow();
|
|
}
|
|
}
|
|
|
|
/// "destroy" signal for surface
|
|
fn gtkDestroy(_: *gtk.GLArea, self: *Surface) callconv(.c) void {
|
|
log.debug("gl destroy", .{});
|
|
|
|
const alloc = self.app.core_app.alloc;
|
|
self.deinit();
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
/// Scale x/y by the GDK device scale.
|
|
fn scaledCoordinates(
|
|
self: *const Surface,
|
|
x: f64,
|
|
y: f64,
|
|
) struct {
|
|
x: f64,
|
|
y: f64,
|
|
} {
|
|
const gl_are_widget = self.gl_area.as(gtk.Widget);
|
|
const scale_factor: f64 = @floatFromInt(
|
|
gl_are_widget.getScaleFactor(),
|
|
);
|
|
|
|
return .{
|
|
.x = x * scale_factor,
|
|
.y = y * scale_factor,
|
|
};
|
|
}
|
|
|
|
fn gtkMouseDown(
|
|
gesture: *gtk.GestureClick,
|
|
_: c_int,
|
|
x: f64,
|
|
y: f64,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
|
|
|
|
const gtk_mods = event.getModifierState();
|
|
|
|
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
|
|
// If we don't have focus, grab it.
|
|
const gl_area_widget = self.gl_area.as(gtk.Widget);
|
|
if (gl_area_widget.hasFocus() == 0) {
|
|
self.grabFocus();
|
|
}
|
|
|
|
const consumed = self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| {
|
|
log.err("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
|
|
// If a right click isn't consumed, mouseButtonCallback selects the hovered
|
|
// word and returns false. We can use this to handle the context menu
|
|
// opening under normal scenarios.
|
|
if (!consumed and button == .right) {
|
|
self.context_menu.popupAt(@intFromFloat(x), @intFromFloat(y));
|
|
}
|
|
}
|
|
|
|
fn gtkMouseUp(
|
|
gesture: *gtk.GestureClick,
|
|
_: c_int,
|
|
_: f64,
|
|
_: f64,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return;
|
|
|
|
const gtk_mods = event.getModifierState();
|
|
|
|
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
|
|
_ = self.core_surface.mouseButtonCallback(.release, button, mods) catch |err| {
|
|
log.err("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkMouseMotion(
|
|
ec: *gtk.EventControllerMotion,
|
|
x: f64,
|
|
y: f64,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
const event = ec.as(gtk.EventController).getCurrentEvent() orelse return;
|
|
|
|
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(self.cursor_pos.x - pos.x) < 1 and
|
|
@abs(self.cursor_pos.y - pos.y) < 1;
|
|
|
|
if (!is_cursor_still) {
|
|
// If we don't have focus, and we want it, grab it.
|
|
const gl_area_widget = self.gl_area.as(gtk.Widget);
|
|
if (gl_area_widget.hasFocus() == 0 and self.app.config.@"focus-follows-mouse") {
|
|
self.grabFocus();
|
|
}
|
|
|
|
// Our pos changed, update
|
|
self.cursor_pos = pos;
|
|
|
|
// Get our modifiers
|
|
const gtk_mods = event.getModifierState();
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
|
|
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
|
|
log.err("error in cursor pos callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
fn gtkMouseLeave(
|
|
ec_motion: *gtk.EventControllerMotion,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return;
|
|
|
|
// Get our modifiers
|
|
const gtk_mods = event.getModifierState();
|
|
const mods = gtk_key.translateMods(gtk_mods);
|
|
self.core_surface.cursorPosCallback(.{ .x = -1, .y = -1 }, mods) catch |err| {
|
|
log.err("error in cursor pos callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkMouseScrollPrecisionBegin(
|
|
_: *gtk.EventControllerScroll,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
self.precision_scroll = true;
|
|
}
|
|
|
|
fn gtkMouseScrollPrecisionEnd(
|
|
_: *gtk.EventControllerScroll,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
self.precision_scroll = false;
|
|
}
|
|
|
|
fn gtkMouseScroll(
|
|
_: *gtk.EventControllerScroll,
|
|
x: f64,
|
|
y: f64,
|
|
self: *Surface,
|
|
) callconv(.c) c_int {
|
|
const scaled = self.scaledCoordinates(x, y);
|
|
|
|
// GTK doesn't support any of the scroll mods.
|
|
const scroll_mods: input.ScrollMods = .{ .precision = self.precision_scroll };
|
|
// Multiply precision scrolls by 10 to get a better response from touchpad scrolling
|
|
const multiplier: f64 = if (self.precision_scroll) 10.0 else 1.0;
|
|
|
|
self.core_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.err("error in scroll callback err={}", .{err});
|
|
return 0;
|
|
};
|
|
|
|
return 1;
|
|
}
|
|
|
|
fn gtkKeyPressed(
|
|
ec_key: *gtk.EventControllerKey,
|
|
keyval: c_uint,
|
|
keycode: c_uint,
|
|
gtk_mods: gdk.ModifierType,
|
|
self: *Surface,
|
|
) callconv(.c) c_int {
|
|
return @intFromBool(self.keyEvent(
|
|
.press,
|
|
ec_key,
|
|
keyval,
|
|
keycode,
|
|
gtk_mods,
|
|
));
|
|
}
|
|
|
|
fn gtkKeyReleased(
|
|
ec_key: *gtk.EventControllerKey,
|
|
keyval: c_uint,
|
|
keycode: c_uint,
|
|
state: gdk.ModifierType,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
_ = self.keyEvent(
|
|
.release,
|
|
ec_key,
|
|
keyval,
|
|
keycode,
|
|
state,
|
|
);
|
|
}
|
|
|
|
/// 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("GTKIM: 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;
|
|
|
|
// The block below is all related to input method handling. See the function
|
|
// comment for some high level details and then the comments within
|
|
// the block for more specifics.
|
|
{
|
|
// This can trigger an input method so we need to notify the im context
|
|
// where the cursor is so it can render the dropdowns in the correct
|
|
// place.
|
|
const ime_point = self.core_surface.imePoint();
|
|
self.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.
|
|
self.in_keyevent = if (self.im_composing) .composing else .not_composing;
|
|
defer self.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 = self.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 (self.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 (self.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 (self.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 self.im_len = 0;
|
|
|
|
// Get the keyvals for this event.
|
|
const keyval_unicode = gdk.keyvalToUnicode(keyval);
|
|
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
|
|
self.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,
|
|
&self.app.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,
|
|
// self.im_composing,
|
|
// self.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 (self.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, &self.im_buf)) |len| {
|
|
self.im_len = len;
|
|
} else |_| {}
|
|
}
|
|
}
|
|
|
|
// Invoke the core Ghostty logic to handle this input.
|
|
const effect = self.core_surface.keyCallback(.{
|
|
.action = action,
|
|
.key = physical_key,
|
|
.mods = mods,
|
|
.consumed_mods = consumed_mods,
|
|
.composing = self.im_composing,
|
|
.utf8 = self.im_buf[0..self.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 (self.im_composing) {
|
|
self.im_context.as(gtk.IMContext).reset();
|
|
self.core_surface.preeditCallback(null) catch {};
|
|
}
|
|
|
|
return true;
|
|
},
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
fn gtkInputPreeditStart(
|
|
_: *gtk.IMMulticontext,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
// log.warn("GTKIM: preedit start", .{});
|
|
|
|
// Start our composing state for the input method and reset our
|
|
// input buffer to empty.
|
|
self.im_composing = true;
|
|
self.im_len = 0;
|
|
}
|
|
|
|
fn gtkInputPreeditChanged(
|
|
ctx: *gtk.IMMulticontext,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
// 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.
|
|
self.im_composing = true;
|
|
|
|
// 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});
|
|
self.core_surface.preeditCallback(str) catch |err| {
|
|
log.err("error in preedit callback err={}", .{err});
|
|
};
|
|
}
|
|
|
|
fn gtkInputPreeditEnd(
|
|
_: *gtk.IMMulticontext,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
// log.warn("GTKIM: preedit end", .{});
|
|
|
|
// End our composing state for GTK, allowing us to commit the text.
|
|
self.im_composing = false;
|
|
|
|
// End our preedit state in Ghostty core
|
|
self.core_surface.preeditCallback(null) catch |err| {
|
|
log.err("error in preedit callback err={}", .{err});
|
|
};
|
|
}
|
|
|
|
fn gtkInputCommit(
|
|
_: *gtk.IMMulticontext,
|
|
bytes: [*:0]u8,
|
|
self: *Surface,
|
|
) callconv(.c) void {
|
|
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 (self.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 > self.im_buf.len) {
|
|
log.warn("not enough buffer space for input method commit", .{});
|
|
return;
|
|
}
|
|
|
|
// Copy our committed text to the buffer
|
|
@memcpy(self.im_buf[0..str.len], str);
|
|
self.im_len = @intCast(str.len);
|
|
|
|
// log.debug("input commit len={}", .{self.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
|
|
self.im_composing = false;
|
|
|
|
// 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).
|
|
self.core_surface.preeditCallback(null) catch |err| {
|
|
log.err("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).
|
|
_ = self.core_surface.keyCallback(.{
|
|
.action = .press,
|
|
.key = .unidentified,
|
|
.mods = .{},
|
|
.consumed_mods = .{},
|
|
.composing = false,
|
|
.utf8 = str,
|
|
}) catch |err| {
|
|
log.warn("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void {
|
|
if (!self.realized) return;
|
|
|
|
// Notify our IM context
|
|
self.im_context.as(gtk.IMContext).focusIn();
|
|
|
|
// Remove the unfocused widget overlay, if we have one
|
|
if (self.unfocused_widget) |widget| {
|
|
self.overlay.removeOverlay(widget);
|
|
self.unfocused_widget = null;
|
|
}
|
|
|
|
if (self.pwd) |pwd| {
|
|
if (self.container.window()) |window| {
|
|
if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd);
|
|
}
|
|
}
|
|
|
|
// Notify our surface
|
|
self.core_surface.focusCallback(true) catch |err| {
|
|
log.err("error in focus callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void {
|
|
if (!self.realized) return;
|
|
|
|
// Notify our IM context
|
|
self.im_context.as(gtk.IMContext).focusOut();
|
|
|
|
// We only try dimming the surface if we are a split
|
|
switch (self.container) {
|
|
.split_br,
|
|
.split_tl,
|
|
=> self.dimSurface(),
|
|
else => {},
|
|
}
|
|
|
|
self.core_surface.focusCallback(false) catch |err| {
|
|
log.err("error in focus callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
/// Adds the unfocused_widget to the overlay. If the unfocused_widget has
|
|
/// already been added, this is a no-op.
|
|
pub fn dimSurface(self: *Surface) void {
|
|
_ = self.container.window() orelse {
|
|
log.warn("dimSurface invalid for container={}", .{self.container});
|
|
return;
|
|
};
|
|
|
|
// Don't dim surface if context menu is open.
|
|
// This means we got unfocused due to it opening.
|
|
if (self.context_menu.isVisible()) return;
|
|
|
|
// If there's already an unfocused_widget do nothing;
|
|
if (self.unfocused_widget) |_| return;
|
|
|
|
self.unfocused_widget = unfocused_widget: {
|
|
const drawing_area = gtk.DrawingArea.new();
|
|
const unfocused_widget = drawing_area.as(gtk.Widget);
|
|
unfocused_widget.addCssClass("unfocused-split");
|
|
self.overlay.addOverlay(unfocused_widget);
|
|
break :unfocused_widget unfocused_widget;
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
pub fn present(self: *Surface) void {
|
|
if (self.container.window()) |window| {
|
|
if (self.container.tab()) |tab| {
|
|
if (window.notebook.getTabPosition(tab)) |position|
|
|
_ = window.notebook.gotoNthTab(position);
|
|
}
|
|
window.window.as(gtk.Window).present();
|
|
}
|
|
|
|
self.grabFocus();
|
|
}
|
|
|
|
fn detachFromSplit(self: *Surface) void {
|
|
const split = self.container.split() orelse return;
|
|
switch (self.container.splitSide() orelse unreachable) {
|
|
.top_left => split.detachTopLeft(),
|
|
.bottom_right => split.detachBottomRight(),
|
|
}
|
|
}
|
|
|
|
fn attachToSplit(self: *Surface) void {
|
|
const split = self.container.split() orelse return;
|
|
split.updateChildren();
|
|
}
|
|
|
|
pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void {
|
|
if (new_split_zoom == self.zoomed_in) return;
|
|
const tab = self.container.tab() orelse return;
|
|
|
|
const tab_widget = tab.elem.widget();
|
|
const surface_widget = self.primaryWidget();
|
|
|
|
if (new_split_zoom) {
|
|
self.detachFromSplit();
|
|
tab.box.remove(tab_widget);
|
|
tab.box.append(surface_widget);
|
|
} else {
|
|
tab.box.remove(surface_widget);
|
|
self.attachToSplit();
|
|
tab.box.append(tab_widget);
|
|
}
|
|
|
|
self.zoomed_in = new_split_zoom;
|
|
self.grabFocus();
|
|
}
|
|
|
|
pub fn toggleSplitZoom(self: *Surface) void {
|
|
self.setSplitZoom(!self.zoomed_in);
|
|
}
|
|
|
|
/// Handle items being dropped on our surface.
|
|
fn gtkDrop(
|
|
_: *gtk.DropTarget,
|
|
value: *gobject.Value,
|
|
_: f64,
|
|
_: f64,
|
|
self: *Surface,
|
|
) callconv(.c) c_int {
|
|
const alloc = self.app.core_app.alloc;
|
|
|
|
if (g_value_holds(value, gdk.FileList.getGObjectType())) {
|
|
var data = std.ArrayList(u8).init(alloc);
|
|
defer data.deinit();
|
|
|
|
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
|
|
.child_writer = data.writer(),
|
|
};
|
|
const writer = shell_escape_writer.writer();
|
|
|
|
const unboxed = value.getBoxed() orelse return 0;
|
|
const fl: *gdk.FileList = @ptrCast(@alignCast(unboxed));
|
|
var list: ?*glib.SList = fl.getFiles();
|
|
|
|
while (list) |item| : (list = item.f_next) {
|
|
const file: *gio.File = @ptrCast(@alignCast(item.f_data orelse continue));
|
|
const path = file.getPath() orelse continue;
|
|
|
|
writer.writeAll(std.mem.span(path)) catch |err| {
|
|
log.err("unable to write path to buffer: {}", .{err});
|
|
continue;
|
|
};
|
|
writer.writeAll("\n") catch |err| {
|
|
log.err("unable to write to buffer: {}", .{err});
|
|
continue;
|
|
};
|
|
}
|
|
|
|
const string = data.toOwnedSliceSentinel(0) catch |err| {
|
|
log.err("unable to convert to a slice: {}", .{err});
|
|
return 0;
|
|
};
|
|
defer alloc.free(string);
|
|
|
|
self.doPaste(string);
|
|
|
|
return 1;
|
|
}
|
|
|
|
if (g_value_holds(value, gio.File.getGObjectType())) {
|
|
const object = value.getObject() orelse return 0;
|
|
const file = gobject.ext.cast(gio.File, object) orelse return 0;
|
|
const path = file.getPath() orelse return 0;
|
|
var data = std.ArrayList(u8).init(alloc);
|
|
defer data.deinit();
|
|
|
|
var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{
|
|
.child_writer = data.writer(),
|
|
};
|
|
const writer = shell_escape_writer.writer();
|
|
writer.writeAll(std.mem.span(path)) catch |err| {
|
|
log.err("unable to write path to buffer: {}", .{err});
|
|
return 0;
|
|
};
|
|
writer.writeAll("\n") catch |err| {
|
|
log.err("unable to write to buffer: {}", .{err});
|
|
return 0;
|
|
};
|
|
|
|
const string = data.toOwnedSliceSentinel(0) catch |err| {
|
|
log.err("unable to convert to a slice: {}", .{err});
|
|
return 0;
|
|
};
|
|
defer alloc.free(string);
|
|
|
|
self.doPaste(string);
|
|
|
|
return 1;
|
|
}
|
|
|
|
if (g_value_holds(value, gobject.ext.types.string)) {
|
|
if (value.getString()) |string| {
|
|
const text = std.mem.span(string);
|
|
if (text.len > 0) self.doPaste(text);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
fn doPaste(self: *Surface, data: [:0]const u8) void {
|
|
if (data.len == 0) return;
|
|
|
|
self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) {
|
|
error.UnsafePaste,
|
|
error.UnauthorizedPaste,
|
|
=> {
|
|
ClipboardConfirmationWindow.create(
|
|
self.app,
|
|
data,
|
|
&self.core_surface,
|
|
.paste,
|
|
self.is_secure_input,
|
|
) catch |window_err| {
|
|
log.err("failed to create clipboard confirmation window err={}", .{window_err});
|
|
};
|
|
},
|
|
error.OutOfMemory,
|
|
error.NoSpaceLeft,
|
|
=> log.err("failed to complete clipboard request err={}", .{err}),
|
|
};
|
|
}
|
|
|
|
pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap {
|
|
const alloc = self.app.core_app.alloc;
|
|
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");
|
|
}
|
|
|
|
if (self.container.window()) |window| {
|
|
// On some window protocols we might want to add specific
|
|
// environment variables to subprocesses, such as WINDOWID on X11.
|
|
try window.winproto.addSubprocessEnv(&env);
|
|
}
|
|
|
|
return env;
|
|
}
|
|
|
|
/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's
|
|
/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it.
|
|
fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool {
|
|
if (value_) |value| {
|
|
if (value.f_g_type == g_type) return true;
|
|
return gobject.typeCheckValueHolds(value, g_type) != 0;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void {
|
|
if (!adw_version.supportsDialogs()) return;
|
|
const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?;
|
|
const self: *Surface = @ptrCast(@alignCast(ud));
|
|
|
|
const response = dialog.chooseFinish(result);
|
|
if (std.mem.orderZ(u8, "ok", response) == .eq) {
|
|
const title_entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?;
|
|
const title = std.mem.span(title_entry.getBuffer().getText());
|
|
|
|
// if the new title is empty and the user has set the title previously, restore the terminal provided title
|
|
if (title.len == 0) {
|
|
if (self.getTerminalTitle()) |terminal_title| {
|
|
self.setTitle(terminal_title, .user) catch |err| {
|
|
log.err("failed to set title={}", .{err});
|
|
};
|
|
self.app.core_app.alloc.free(self.title_from_terminal.?);
|
|
self.title_from_terminal = null;
|
|
}
|
|
} else if (title.len > 0) {
|
|
// if this is the first time the user is setting the title, save the current terminal provided title
|
|
if (self.title_from_terminal == null and self.title_text != null) {
|
|
self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) {
|
|
error.OutOfMemory => {
|
|
log.err("failed to allocate memory for title={}", .{err});
|
|
return;
|
|
},
|
|
};
|
|
}
|
|
|
|
self.setTitle(title, .user) catch |err| {
|
|
log.err("failed to set title={}", .{err});
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void {
|
|
switch (value) {
|
|
.on => self.is_secure_input = true,
|
|
.off => self.is_secure_input = false,
|
|
.toggle => self.is_secure_input = !self.is_secure_input,
|
|
}
|
|
}
|
|
|
|
pub fn ringBell(self: *Surface) !void {
|
|
const features = self.app.config.@"bell-features";
|
|
const window = self.container.window() orelse {
|
|
log.warn("failed to ring bell: surface is not attached to any window", .{});
|
|
return;
|
|
};
|
|
|
|
// System beep
|
|
if (features.system) system: {
|
|
const surface = window.window.as(gtk.Native).getSurface() orelse break :system;
|
|
surface.beep();
|
|
}
|
|
|
|
if (features.audio) audio: {
|
|
// Play a user-specified audio file.
|
|
|
|
const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) {
|
|
.optional => |path| .{ path, false },
|
|
.required => |path| .{ path, true },
|
|
};
|
|
|
|
const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0);
|
|
|
|
std.debug.assert(std.fs.path.isAbsolute(pathname));
|
|
const media_file = gtk.MediaFile.newForFilename(pathname);
|
|
|
|
if (required) {
|
|
_ = gobject.Object.signals.notify.connect(
|
|
media_file,
|
|
?*anyopaque,
|
|
gtkStreamError,
|
|
null,
|
|
.{ .detail = "error" },
|
|
);
|
|
}
|
|
_ = gobject.Object.signals.notify.connect(
|
|
media_file,
|
|
?*anyopaque,
|
|
gtkStreamEnded,
|
|
null,
|
|
.{ .detail = "ended" },
|
|
);
|
|
|
|
const media_stream = media_file.as(gtk.MediaStream);
|
|
media_stream.setVolume(volume);
|
|
media_stream.play();
|
|
}
|
|
|
|
if (features.attention) {
|
|
// Request user attention
|
|
window.winproto.setUrgent(true) catch |err| {
|
|
log.err("failed to request user attention={}", .{err});
|
|
};
|
|
}
|
|
|
|
// Mark tab as needing attention
|
|
if (self.container.tab()) |tab| tab: {
|
|
const page = window.notebook.getTabPage(tab) orelse break :tab;
|
|
|
|
// Need attention if we're not the currently selected tab
|
|
if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true));
|
|
}
|
|
}
|
|
|
|
/// Handle a stream that is in an error state.
|
|
fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void {
|
|
const path = path: {
|
|
const file = media_file.getFile() orelse break :path null;
|
|
break :path file.getPath();
|
|
};
|
|
defer if (path) |p| glib.free(p);
|
|
|
|
const media_stream = media_file.as(gtk.MediaStream);
|
|
const err = media_stream.getError() orelse return;
|
|
|
|
log.warn("error playing bell from {s}: {s} {d} {s}", .{
|
|
path orelse "<<unknown>>",
|
|
glib.quarkToString(err.f_domain),
|
|
err.f_code,
|
|
err.f_message orelse "",
|
|
});
|
|
}
|
|
|
|
/// Stream is finished, release the memory.
|
|
fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void {
|
|
media_file.unref();
|
|
}
|
|
|
|
/// Show native GUI element with a notification that the child process has
|
|
/// closed. Return `true` if we are able to show the GUI notification, and
|
|
/// `false` if we are not.
|
|
pub fn showChildExited(self: *Surface, info: apprt.surface.Message.ChildExited) error{}!bool {
|
|
if (!adw_version.supportsBanner()) return false;
|
|
|
|
const warning_text = if (info.exit_code == 0)
|
|
i18n._("Process exited normally. Press any key to close the terminal.")
|
|
else
|
|
i18n._("Process exited abnormally. Press any key to close the terminal.");
|
|
|
|
const banner = adw.Banner.new(warning_text);
|
|
banner.setRevealed(1);
|
|
|
|
const banner_widget = banner.as(gtk.Widget);
|
|
banner_widget.setHalign(.fill);
|
|
banner_widget.setValign(.end);
|
|
|
|
if (info.exit_code == 0)
|
|
banner_widget.addCssClass("child_exited_normally")
|
|
else
|
|
banner_widget.addCssClass("child_exited_abnormally");
|
|
|
|
self.overlay.addOverlay(banner_widget);
|
|
|
|
return true;
|
|
}
|