mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-22 03:36:14 +03:00
1636 lines
58 KiB
Zig
1636 lines
58 KiB
Zig
//! Application runtime that uses GTK4.
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const glfw = @import("glfw");
|
|
const apprt = @import("../apprt.zig");
|
|
const font = @import("../font/main.zig");
|
|
const input = @import("../input.zig");
|
|
const CoreApp = @import("../App.zig");
|
|
const CoreSurface = @import("../Surface.zig");
|
|
const Config = @import("../config.zig").Config;
|
|
|
|
pub const c = @cImport({
|
|
@cInclude("gtk/gtk.h");
|
|
});
|
|
|
|
// We need native X11 access to access the primary clipboard.
|
|
const glfw_native = glfw.Native(.{ .x11 = true });
|
|
|
|
/// Compatibility with gobject < 2.74
|
|
const G_CONNECT_DEFAULT = if (@hasDecl(c, "G_CONNECT_DEFAULT"))
|
|
c.G_CONNECT_DEFAULT
|
|
else
|
|
0;
|
|
|
|
const log = std.log.scoped(.gtk);
|
|
|
|
/// App is the entrypoint for the application. This is called after all
|
|
/// of the runtime-agnostic initialization is complete and we're ready
|
|
/// to start.
|
|
///
|
|
/// There is only ever one App instance per process. This is because most
|
|
/// application frameworks also have this restriction so it simplifies
|
|
/// the assumptions.
|
|
pub const App = struct {
|
|
pub const Options = struct {};
|
|
|
|
core_app: *CoreApp,
|
|
config: Config,
|
|
|
|
app: *c.GtkApplication,
|
|
ctx: *c.GMainContext,
|
|
|
|
cursor_default: *c.GdkCursor,
|
|
cursor_ibeam: *c.GdkCursor,
|
|
|
|
/// This is set to false when the main loop should exit.
|
|
running: bool = true,
|
|
|
|
pub fn init(core_app: *CoreApp, opts: Options) !App {
|
|
_ = opts;
|
|
|
|
// This is super weird, but we still use GLFW with GTK only so that
|
|
// we can tap into their folklore logic to get screen DPI. If we can
|
|
// figure out a reliable way to determine this ourselves, we can get
|
|
// rid of this dep.
|
|
if (!glfw.init(.{})) return error.GlfwInitFailed;
|
|
|
|
// Load our configuration
|
|
var config = try Config.load(core_app.alloc);
|
|
errdefer config.deinit();
|
|
|
|
// Our uniqueness ID is based on whether we're in a debug mode or not.
|
|
// In debug mode we want to be separate so we can develop Ghostty in
|
|
// Ghostty.
|
|
const uniqueness_id = "com.mitchellh.ghostty" ++ if (builtin.mode == .Debug) "-debug" else "";
|
|
|
|
// Create our GTK Application which encapsulates our process.
|
|
const app = @as(?*c.GtkApplication, @ptrCast(c.gtk_application_new(
|
|
uniqueness_id,
|
|
|
|
// GTK >= 2.74
|
|
if (@hasDecl(c, "G_APPLICATION_DEFAULT_FLAGS"))
|
|
c.G_APPLICATION_DEFAULT_FLAGS
|
|
else
|
|
c.G_APPLICATION_FLAGS_NONE,
|
|
))) orelse return error.GtkInitFailed;
|
|
errdefer c.g_object_unref(app);
|
|
_ = c.g_signal_connect_data(
|
|
app,
|
|
"activate",
|
|
c.G_CALLBACK(&activate),
|
|
core_app,
|
|
null,
|
|
G_CONNECT_DEFAULT,
|
|
);
|
|
|
|
// We don't use g_application_run, we want to manually control the
|
|
// loop so we have to do the same things the run function does:
|
|
// https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533
|
|
const ctx = c.g_main_context_default() orelse return error.GtkContextFailed;
|
|
if (c.g_main_context_acquire(ctx) == 0) return error.GtkContextAcquireFailed;
|
|
errdefer c.g_main_context_release(ctx);
|
|
|
|
const gapp = @as(*c.GApplication, @ptrCast(app));
|
|
var err_: ?*c.GError = null;
|
|
if (c.g_application_register(
|
|
gapp,
|
|
null,
|
|
@ptrCast(&err_),
|
|
) == 0) {
|
|
if (err_) |err| {
|
|
log.warn("error registering application: {s}", .{err.message});
|
|
c.g_error_free(err);
|
|
}
|
|
return error.GtkApplicationRegisterFailed;
|
|
}
|
|
|
|
// This just calls the "activate" signal but its part of the normal
|
|
// startup routine so we just call it:
|
|
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
|
|
c.g_application_activate(gapp);
|
|
|
|
// Get our cursors
|
|
const cursor_default = c.gdk_cursor_new_from_name("default", null).?;
|
|
errdefer c.g_object_unref(cursor_default);
|
|
const cursor_ibeam = c.gdk_cursor_new_from_name("text", cursor_default).?;
|
|
errdefer c.g_object_unref(cursor_ibeam);
|
|
|
|
return .{
|
|
.core_app = core_app,
|
|
.app = app,
|
|
.config = config,
|
|
.ctx = ctx,
|
|
.cursor_default = cursor_default,
|
|
.cursor_ibeam = cursor_ibeam,
|
|
|
|
// If we are NOT the primary instance, then we never want to run.
|
|
// This means that another instance of the GTK app is running and
|
|
// our "activate" call above will open a window.
|
|
.running = c.g_application_get_is_remote(gapp) == 0,
|
|
};
|
|
}
|
|
|
|
// Terminate the application. The application will not be restarted after
|
|
// this so all global state can be cleaned up.
|
|
pub fn terminate(self: *App) void {
|
|
c.g_settings_sync();
|
|
while (c.g_main_context_iteration(self.ctx, 0) != 0) {}
|
|
c.g_main_context_release(self.ctx);
|
|
c.g_object_unref(self.app);
|
|
|
|
c.g_object_unref(self.cursor_ibeam);
|
|
c.g_object_unref(self.cursor_default);
|
|
|
|
self.config.deinit();
|
|
|
|
glfw.terminate();
|
|
}
|
|
|
|
/// Reload the configuration. This should return the new configuration.
|
|
/// The old value can be freed immediately at this point assuming a
|
|
/// successful return.
|
|
///
|
|
/// The returned pointer value is only valid for a stable self pointer.
|
|
pub fn reloadConfig(self: *App) !?*const Config {
|
|
// Load our configuration
|
|
var config = try Config.load(self.core_app.alloc);
|
|
errdefer config.deinit();
|
|
|
|
// Update the existing config, be sure to clean up the old one.
|
|
self.config.deinit();
|
|
self.config = config;
|
|
|
|
return &self.config;
|
|
}
|
|
|
|
pub fn wakeup(self: App) void {
|
|
_ = self;
|
|
c.g_main_context_wakeup(null);
|
|
}
|
|
|
|
/// Run the event loop. This doesn't return until the app exits.
|
|
pub fn run(self: *App) !void {
|
|
while (self.running) {
|
|
_ = c.g_main_context_iteration(self.ctx, 1);
|
|
|
|
// Tick the terminal app
|
|
const should_quit = try self.core_app.tick(self);
|
|
if (should_quit) self.quit();
|
|
}
|
|
}
|
|
|
|
/// Close the given surface.
|
|
pub fn redrawSurface(self: *App, surface: *Surface) void {
|
|
_ = self;
|
|
surface.invalidate();
|
|
}
|
|
|
|
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
|
|
const alloc = self.core_app.alloc;
|
|
|
|
// Allocate a fixed pointer for our window. We try to minimize
|
|
// allocations but windows and other GUI requirements are so minimal
|
|
// compared to the steady-state terminal operation so we use heap
|
|
// allocation for this.
|
|
//
|
|
// The allocation is owned by the GtkWindow created. It will be
|
|
// freed when the window is closed.
|
|
var window = try alloc.create(Window);
|
|
errdefer alloc.destroy(window);
|
|
try window.init(self);
|
|
|
|
// Add our initial tab
|
|
try window.newTab(parent_);
|
|
}
|
|
|
|
fn quit(self: *App) void {
|
|
// If we have no toplevel windows, then we're done.
|
|
const list = c.gtk_window_list_toplevels();
|
|
if (list == null) {
|
|
self.running = false;
|
|
return;
|
|
}
|
|
c.g_list_free(list);
|
|
|
|
// If we have windows, then we want to confirm that we want to exit.
|
|
const alert = c.gtk_message_dialog_new(
|
|
null,
|
|
c.GTK_DIALOG_MODAL,
|
|
c.GTK_MESSAGE_QUESTION,
|
|
c.GTK_BUTTONS_YES_NO,
|
|
"Quit Ghostty?",
|
|
);
|
|
c.gtk_message_dialog_format_secondary_text(
|
|
@ptrCast(alert),
|
|
"All active terminal sessions will be terminated.",
|
|
);
|
|
|
|
// We want the "yes" to appear destructive.
|
|
const yes_widget = c.gtk_dialog_get_widget_for_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_YES,
|
|
);
|
|
c.gtk_widget_add_css_class(yes_widget, "destructive-action");
|
|
|
|
// We want the "no" to be the default action
|
|
c.gtk_dialog_set_default_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_NO,
|
|
);
|
|
|
|
_ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kQuitConfirmation), self, null, G_CONNECT_DEFAULT);
|
|
|
|
c.gtk_widget_show(alert);
|
|
}
|
|
|
|
fn gtkQuitConfirmation(
|
|
alert: *c.GtkMessageDialog,
|
|
response: c.gint,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
_ = ud;
|
|
|
|
// Close the alert window
|
|
c.gtk_window_destroy(@ptrCast(alert));
|
|
|
|
// If we didn't confirm then we're done
|
|
if (response != c.GTK_RESPONSE_YES) return;
|
|
|
|
// Force close all open windows
|
|
const list = c.gtk_window_list_toplevels();
|
|
defer c.g_list_free(list);
|
|
c.g_list_foreach(list, struct {
|
|
fn callback(data: c.gpointer, _: c.gpointer) callconv(.C) void {
|
|
const ptr = data orelse return;
|
|
const widget: *c.GtkWidget = @ptrCast(@alignCast(ptr));
|
|
const window: *c.GtkWindow = @ptrCast(widget);
|
|
c.gtk_window_destroy(window);
|
|
}
|
|
}.callback, null);
|
|
}
|
|
|
|
/// This is called by the "activate" signal. This is sent on program
|
|
/// startup and also when a secondary instance launches and requests
|
|
/// a new window.
|
|
fn activate(app: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void {
|
|
_ = app;
|
|
|
|
const core_app: *CoreApp = @ptrCast(@alignCast(ud orelse return));
|
|
|
|
// Queue a new window
|
|
_ = core_app.mailbox.push(.{
|
|
.new_window = .{},
|
|
}, .{ .forever = {} });
|
|
}
|
|
};
|
|
|
|
/// The state for a single, real GTK window.
|
|
const Window = struct {
|
|
const TAB_CLOSE_PAGE = "tab_close_page";
|
|
const TAB_CLOSE_SURFACE = "tab_close_surface";
|
|
|
|
app: *App,
|
|
|
|
/// Our window
|
|
window: *c.GtkWindow,
|
|
|
|
/// The notebook (tab grouping) for this window.
|
|
notebook: *c.GtkNotebook,
|
|
|
|
/// The background CSS for the window (if any).
|
|
css_window_background: ?[]u8 = null,
|
|
|
|
/// The resources directory for the icon (if any).
|
|
icon_search_dir: ?[:0]const u8 = null,
|
|
|
|
pub fn init(self: *Window, app: *App) !void {
|
|
// Set up our own state
|
|
self.* = .{
|
|
.app = app,
|
|
.window = undefined,
|
|
.notebook = undefined,
|
|
};
|
|
|
|
// Create the window
|
|
const window = c.gtk_application_window_new(app.app);
|
|
const gtk_window: *c.GtkWindow = @ptrCast(window);
|
|
errdefer c.gtk_window_destroy(gtk_window);
|
|
self.window = gtk_window;
|
|
c.gtk_window_set_title(gtk_window, "Ghostty");
|
|
c.gtk_window_set_default_size(gtk_window, 1000, 600);
|
|
|
|
// If we don't have the icon then we'll try to add our resources dir
|
|
// to the search path and see if we can find it there.
|
|
const icon_name = "com.mitchellh.ghostty";
|
|
const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(window));
|
|
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: {
|
|
const base = self.app.core_app.resources_dir orelse {
|
|
log.info("gtk app missing Ghostty icon and no resources dir detected", .{});
|
|
log.info("gtk app will not have Ghostty icon", .{});
|
|
break :icon;
|
|
};
|
|
|
|
// Note that this method for adding the icon search path is
|
|
// a fallback mechanism. The recommended mechanism is the
|
|
// Freedesktop Icon Theme Specification. We distribute a ".desktop"
|
|
// file in zig-out/share that should be installed to the proper
|
|
// place.
|
|
const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base});
|
|
self.icon_search_dir = dir;
|
|
c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr);
|
|
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) {
|
|
log.warn("Ghostty icon for gtk app not found", .{});
|
|
}
|
|
}
|
|
c.gtk_window_set_icon_name(gtk_window, icon_name);
|
|
|
|
// Apply background opacity if we have it
|
|
if (app.config.@"background-opacity" < 1) {
|
|
c.gtk_widget_set_opacity(@ptrCast(window), app.config.@"background-opacity");
|
|
}
|
|
|
|
c.gtk_widget_show(window);
|
|
_ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, G_CONNECT_DEFAULT);
|
|
|
|
// Create a notebook to hold our tabs.
|
|
const notebook_widget = c.gtk_notebook_new();
|
|
const notebook: *c.GtkNotebook = @ptrCast(notebook_widget);
|
|
self.notebook = notebook;
|
|
c.gtk_notebook_set_tab_pos(notebook, c.GTK_POS_TOP);
|
|
c.gtk_notebook_set_scrollable(notebook, 1);
|
|
c.gtk_notebook_set_show_tabs(notebook, 0);
|
|
c.gtk_notebook_set_show_border(notebook, 0);
|
|
|
|
// Create our add button for new tabs
|
|
const notebook_add_btn = c.gtk_button_new_from_icon_name("list-add-symbolic");
|
|
c.gtk_notebook_set_action_widget(notebook, notebook_add_btn, c.GTK_PACK_END);
|
|
_ = c.g_signal_connect_data(notebook_add_btn, "clicked", c.G_CALLBACK(>kTabAddClick), self, null, G_CONNECT_DEFAULT);
|
|
|
|
// The notebook is our main child
|
|
c.gtk_window_set_child(gtk_window, notebook_widget);
|
|
}
|
|
|
|
pub fn deinit(self: *Window) void {
|
|
if (self.css_window_background) |ptr| self.app.core_app.alloc.free(ptr);
|
|
if (self.icon_search_dir) |ptr| self.app.core_app.alloc.free(ptr);
|
|
}
|
|
|
|
/// Add a new tab to this window.
|
|
pub fn newTab(self: *Window, parent_: ?*CoreSurface) !void {
|
|
// Grab a surface allocation we'll need it later.
|
|
var surface = try self.app.core_app.alloc.create(Surface);
|
|
errdefer self.app.core_app.alloc.destroy(surface);
|
|
|
|
// Inherit the parent's font size if we are configured to.
|
|
const font_size: ?font.face.DesiredSize = font_size: {
|
|
if (!self.app.config.@"window-inherit-font-size") break :font_size null;
|
|
const parent = parent_ orelse break :font_size null;
|
|
break :font_size parent.font_size;
|
|
};
|
|
|
|
// Build our tab label
|
|
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
|
|
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
|
|
const label_text = c.gtk_label_new("Ghostty");
|
|
c.gtk_box_append(label_box, label_text);
|
|
|
|
const label_close_widget = c.gtk_button_new_from_icon_name("window-close");
|
|
const label_close = @as(*c.GtkButton, @ptrCast(label_close_widget));
|
|
c.gtk_button_set_has_frame(label_close, 0);
|
|
c.gtk_box_append(label_box, label_close_widget);
|
|
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(>kTabCloseClick), self, null, G_CONNECT_DEFAULT);
|
|
|
|
// Initialize the GtkGLArea and attach it to our surface.
|
|
// The surface starts in the "unrealized" state because we have to
|
|
// wait for the "realize" callback from GTK to know that the OpenGL
|
|
// context is ready. See Surface docs for more info.
|
|
const gl_area = c.gtk_gl_area_new();
|
|
try surface.init(self.app, .{
|
|
.window = self,
|
|
.gl_area = @ptrCast(gl_area),
|
|
.title_label = @ptrCast(label_text),
|
|
.font_size = font_size,
|
|
});
|
|
errdefer surface.deinit();
|
|
const page_idx = c.gtk_notebook_append_page(self.notebook, gl_area, label_box_widget);
|
|
if (page_idx < 0) {
|
|
log.warn("failed to add surface to notebook", .{});
|
|
return error.GtkAppendPageFailed;
|
|
}
|
|
|
|
// Tab settings
|
|
c.gtk_notebook_set_tab_reorderable(self.notebook, gl_area, 1);
|
|
|
|
// If we have multiple tabs, show the tab bar.
|
|
if (c.gtk_notebook_get_n_pages(self.notebook) > 1) {
|
|
c.gtk_notebook_set_show_tabs(self.notebook, 1);
|
|
}
|
|
|
|
// Set the userdata of the close button so it points to this page.
|
|
const page = c.gtk_notebook_get_page(self.notebook, gl_area) orelse
|
|
return error.GtkNotebookPageNotFound;
|
|
c.g_object_set_data(@ptrCast(label_close), TAB_CLOSE_SURFACE, surface);
|
|
c.g_object_set_data(@ptrCast(label_close), TAB_CLOSE_PAGE, page);
|
|
c.g_object_set_data(@ptrCast(gl_area), TAB_CLOSE_PAGE, page);
|
|
|
|
// Switch to the new tab
|
|
c.gtk_notebook_set_current_page(self.notebook, page_idx);
|
|
|
|
// We need to grab focus after it is added to the window. When
|
|
// creating a window we want to always focus on the widget.
|
|
const widget = @as(*c.GtkWidget, @ptrCast(gl_area));
|
|
_ = c.gtk_widget_grab_focus(widget);
|
|
}
|
|
|
|
/// Close the tab for the given notebook page. This will automatically
|
|
/// handle closing the window if there are no more tabs.
|
|
fn closeTab(self: *Window, page: *c.GtkNotebookPage) void {
|
|
// Remove the page
|
|
const page_idx = getNotebookPageIndex(page);
|
|
c.gtk_notebook_remove_page(self.notebook, page_idx);
|
|
|
|
const remaining = c.gtk_notebook_get_n_pages(self.notebook);
|
|
switch (remaining) {
|
|
// If we have no more tabs we close the window
|
|
0 => c.gtk_window_destroy(self.window),
|
|
|
|
// If we have one more tab we hide the tab bar
|
|
1 => c.gtk_notebook_set_show_tabs(self.notebook, 0),
|
|
|
|
else => {},
|
|
}
|
|
|
|
// If we have remaining tabs, we need to make sure we grab focus.
|
|
if (remaining > 0) self.focusCurrentTab();
|
|
}
|
|
|
|
/// Close the surface. This surface must be definitely part of this window.
|
|
fn closeSurface(self: *Window, surface: *Surface) void {
|
|
assert(surface.window == self);
|
|
self.closeTab(getNotebookPage(@ptrCast(surface.gl_area)) orelse return);
|
|
}
|
|
|
|
/// Go to the previous tab for a surface.
|
|
fn gotoPreviousTab(self: *Window, surface: *Surface) void {
|
|
const page = getNotebookPage(@ptrCast(surface.gl_area)) orelse return;
|
|
const page_idx = getNotebookPageIndex(page);
|
|
|
|
// The next index is the previous or we wrap around.
|
|
const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: {
|
|
const max = c.gtk_notebook_get_n_pages(self.notebook);
|
|
break :next_idx max -| 1;
|
|
};
|
|
|
|
// Do nothing if we have one tab
|
|
if (next_idx == page_idx) return;
|
|
|
|
c.gtk_notebook_set_current_page(self.notebook, next_idx);
|
|
self.focusCurrentTab();
|
|
}
|
|
|
|
/// Go to the next tab for a surface.
|
|
fn gotoNextTab(self: *Window, surface: *Surface) void {
|
|
const page = getNotebookPage(@ptrCast(surface.gl_area)) orelse return;
|
|
const page_idx = getNotebookPageIndex(page);
|
|
const max = c.gtk_notebook_get_n_pages(self.notebook) -| 1;
|
|
const next_idx = if (page_idx < max) page_idx + 1 else 0;
|
|
if (next_idx == page_idx) return;
|
|
|
|
c.gtk_notebook_set_current_page(self.notebook, next_idx);
|
|
self.focusCurrentTab();
|
|
}
|
|
|
|
/// Go to the specific tab index.
|
|
fn gotoTab(self: *Window, n: usize) void {
|
|
if (n == 0) return;
|
|
const max = c.gtk_notebook_get_n_pages(self.notebook);
|
|
const page_idx = std.math.cast(c_int, n - 1) orelse return;
|
|
if (page_idx < max) {
|
|
c.gtk_notebook_set_current_page(self.notebook, page_idx);
|
|
self.focusCurrentTab();
|
|
}
|
|
}
|
|
|
|
/// Toggle fullscreen for this window.
|
|
fn toggleFullscreen(self: *Window, _: bool) void {
|
|
const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
|
|
if (is_fullscreen == 0) {
|
|
c.gtk_window_fullscreen(self.window);
|
|
} else {
|
|
c.gtk_window_unfullscreen(self.window);
|
|
}
|
|
}
|
|
|
|
/// Grabs focus on the currently selected tab.
|
|
fn focusCurrentTab(self: *Window) void {
|
|
const page_idx = c.gtk_notebook_get_current_page(self.notebook);
|
|
const widget = c.gtk_notebook_get_nth_page(self.notebook, page_idx);
|
|
_ = c.gtk_widget_grab_focus(widget);
|
|
}
|
|
|
|
fn gtkTabAddClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
const parent = self.app.core_app.focusedSurface();
|
|
self.newTab(parent) catch |err| {
|
|
log.warn("error adding new tab: {}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkTabCloseClick(btn: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
|
|
_ = ud;
|
|
const surface: *Surface = @ptrCast(@alignCast(
|
|
c.g_object_get_data(@ptrCast(btn), TAB_CLOSE_SURFACE) orelse return,
|
|
));
|
|
|
|
surface.core_surface.close();
|
|
}
|
|
|
|
fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
|
|
_ = v;
|
|
log.debug("window close request", .{});
|
|
const self = userdataSelf(ud.?);
|
|
|
|
// Setup our basic message
|
|
const alert = c.gtk_message_dialog_new(
|
|
self.window,
|
|
c.GTK_DIALOG_MODAL,
|
|
c.GTK_MESSAGE_QUESTION,
|
|
c.GTK_BUTTONS_YES_NO,
|
|
"Close this window?",
|
|
);
|
|
c.gtk_message_dialog_format_secondary_text(
|
|
@ptrCast(alert),
|
|
"All terminal sessions in this window will be terminated.",
|
|
);
|
|
|
|
// We want the "yes" to appear destructive.
|
|
const yes_widget = c.gtk_dialog_get_widget_for_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_YES,
|
|
);
|
|
c.gtk_widget_add_css_class(yes_widget, "destructive-action");
|
|
|
|
// We want the "no" to be the default action
|
|
c.gtk_dialog_set_default_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_NO,
|
|
);
|
|
|
|
_ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kCloseConfirmation), self, null, G_CONNECT_DEFAULT);
|
|
|
|
c.gtk_widget_show(alert);
|
|
return true;
|
|
}
|
|
|
|
fn gtkCloseConfirmation(
|
|
alert: *c.GtkMessageDialog,
|
|
response: c.gint,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
c.gtk_window_destroy(@ptrCast(alert));
|
|
if (response == c.GTK_RESPONSE_YES) {
|
|
const self = userdataSelf(ud.?);
|
|
c.gtk_window_destroy(self.window);
|
|
}
|
|
}
|
|
|
|
/// "destroy" signal for the window
|
|
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
|
|
_ = v;
|
|
log.debug("window destroy", .{});
|
|
|
|
const self = userdataSelf(ud.?);
|
|
const alloc = self.app.core_app.alloc;
|
|
self.deinit();
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
/// Get the GtkNotebookPage for the given object. You must be sure the
|
|
/// object has the notebook page property set.
|
|
fn getNotebookPage(obj: *c.GObject) ?*c.GtkNotebookPage {
|
|
return @ptrCast(@alignCast(
|
|
c.g_object_get_data(obj, TAB_CLOSE_PAGE) orelse return null,
|
|
));
|
|
}
|
|
|
|
fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int {
|
|
var value: c.GValue = std.mem.zeroes(c.GValue);
|
|
defer c.g_value_unset(&value);
|
|
_ = c.g_value_init(&value, c.G_TYPE_INT);
|
|
c.g_object_get_property(
|
|
@ptrCast(@alignCast(page)),
|
|
"position",
|
|
&value,
|
|
);
|
|
|
|
return c.g_value_get_int(&value);
|
|
}
|
|
|
|
fn userdataSelf(ud: *anyopaque) *Window {
|
|
return @ptrCast(@alignCast(ud));
|
|
}
|
|
};
|
|
|
|
pub const Surface = struct {
|
|
/// This is detected by the OpenGL renderer to move to a single-threaded
|
|
/// draw operation. This basically puts locks around our draw path.
|
|
pub const opengl_single_threaded_draw = true;
|
|
|
|
pub const Options = struct {
|
|
/// The window that this surface is attached to.
|
|
window: *Window,
|
|
|
|
/// The GL area that this surface should draw to.
|
|
gl_area: *c.GtkGLArea,
|
|
|
|
/// The label to use as the title of this surface. This will be
|
|
/// modified with setTitle.
|
|
title_label: ?*c.GtkLabel = null,
|
|
|
|
/// A font size to set on the surface once it is initialized.
|
|
font_size: ?font.face.DesiredSize = null,
|
|
};
|
|
|
|
/// Where the title of this surface will go.
|
|
const Title = union(enum) {
|
|
none: void,
|
|
label: *c.GtkLabel,
|
|
};
|
|
|
|
/// 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 app we're part of
|
|
app: *App,
|
|
|
|
/// The window we're part of
|
|
window: *Window,
|
|
|
|
/// Our GTK area
|
|
gl_area: *c.GtkGLArea,
|
|
|
|
/// Our title label (if there is one).
|
|
title: Title,
|
|
|
|
/// 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,
|
|
clipboard: c.GValue,
|
|
|
|
/// Key input states. See gtkKeyPressed for detailed descriptions.
|
|
in_keypress: bool = false,
|
|
im_context: *c.GtkIMContext,
|
|
im_composing: bool = false,
|
|
im_buf: [128]u8 = undefined,
|
|
im_len: u7 = 0,
|
|
|
|
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
|
const widget = @as(*c.GtkWidget, @ptrCast(opts.gl_area));
|
|
c.gtk_gl_area_set_required_version(opts.gl_area, 3, 3);
|
|
c.gtk_gl_area_set_has_stencil_buffer(opts.gl_area, 0);
|
|
c.gtk_gl_area_set_has_depth_buffer(opts.gl_area, 0);
|
|
c.gtk_gl_area_set_use_es(opts.gl_area, 0);
|
|
|
|
// Key event controller will tell us about raw keypress events.
|
|
const ec_key = c.gtk_event_controller_key_new();
|
|
errdefer c.g_object_unref(ec_key);
|
|
c.gtk_widget_add_controller(widget, ec_key);
|
|
errdefer c.gtk_widget_remove_controller(widget, ec_key);
|
|
|
|
// Focus controller will tell us about focus enter/exit events
|
|
const ec_focus = c.gtk_event_controller_focus_new();
|
|
errdefer c.g_object_unref(ec_focus);
|
|
c.gtk_widget_add_controller(widget, ec_focus);
|
|
errdefer c.gtk_widget_remove_controller(widget, ec_focus);
|
|
|
|
// Create a second key controller so we can receive the raw
|
|
// key-press events BEFORE the input method gets them.
|
|
const ec_key_press = c.gtk_event_controller_key_new();
|
|
errdefer c.g_object_unref(ec_key_press);
|
|
c.gtk_widget_add_controller(widget, ec_key_press);
|
|
errdefer c.gtk_widget_remove_controller(widget, ec_key_press);
|
|
|
|
// Clicks
|
|
const gesture_click = c.gtk_gesture_click_new();
|
|
errdefer c.g_object_unref(gesture_click);
|
|
c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 0);
|
|
c.gtk_widget_add_controller(widget, @ptrCast(gesture_click));
|
|
|
|
// Mouse movement
|
|
const ec_motion = c.gtk_event_controller_motion_new();
|
|
errdefer c.g_object_unref(ec_motion);
|
|
c.gtk_widget_add_controller(widget, ec_motion);
|
|
|
|
// Scroll events
|
|
const ec_scroll = c.gtk_event_controller_scroll_new(
|
|
c.GTK_EVENT_CONTROLLER_SCROLL_BOTH_AXES |
|
|
c.GTK_EVENT_CONTROLLER_SCROLL_DISCRETE,
|
|
);
|
|
errdefer c.g_object_unref(ec_scroll);
|
|
c.gtk_widget_add_controller(widget, ec_scroll);
|
|
|
|
// 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 = c.gtk_im_multicontext_new();
|
|
errdefer c.g_object_unref(im_context);
|
|
|
|
// The GL area has to be focusable so that it can receive events
|
|
c.gtk_widget_set_focusable(widget, 1);
|
|
c.gtk_widget_set_focus_on_click(widget, 1);
|
|
|
|
// When we're over the widget, set the cursor to the ibeam
|
|
c.gtk_widget_set_cursor(widget, app.cursor_ibeam);
|
|
|
|
// Build our result
|
|
self.* = .{
|
|
.app = app,
|
|
.window = opts.window,
|
|
.gl_area = opts.gl_area,
|
|
.title = if (opts.title_label) |label| .{
|
|
.label = label,
|
|
} else .{ .none = {} },
|
|
.core_surface = undefined,
|
|
.font_size = opts.font_size,
|
|
.size = .{ .width = 800, .height = 600 },
|
|
.cursor_pos = .{ .x = 0, .y = 0 },
|
|
.clipboard = std.mem.zeroes(c.GValue),
|
|
.im_context = im_context,
|
|
};
|
|
errdefer self.* = undefined;
|
|
|
|
// GL events
|
|
_ = c.g_signal_connect_data(opts.gl_area, "realize", c.G_CALLBACK(>kRealize), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(opts.gl_area, "destroy", c.G_CALLBACK(>kDestroy), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(opts.gl_area, "render", c.G_CALLBACK(>kRender), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(opts.gl_area, "resize", c.G_CALLBACK(>kResize), self, null, G_CONNECT_DEFAULT);
|
|
|
|
_ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(>kKeyPressed), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(ec_key_press, "key-released", c.G_CALLBACK(>kKeyReleased), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(ec_focus, "enter", c.G_CALLBACK(>kFocusEnter), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(ec_focus, "leave", c.G_CALLBACK(>kFocusLeave), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(gesture_click, "released", c.G_CALLBACK(>kMouseUp), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(ec_motion, "motion", c.G_CALLBACK(>kMouseMotion), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(ec_scroll, "scroll", c.G_CALLBACK(>kMouseScroll), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(im_context, "preedit-start", c.G_CALLBACK(>kInputPreeditStart), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(>kInputPreeditChanged), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(>kInputPreeditEnd), self, null, G_CONNECT_DEFAULT);
|
|
_ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, G_CONNECT_DEFAULT);
|
|
}
|
|
|
|
fn realize(self: *Surface) !void {
|
|
// 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();
|
|
|
|
// 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,
|
|
.{ .rt_app = self.app, .mailbox = &self.app.core_app.mailbox },
|
|
self,
|
|
);
|
|
errdefer self.core_surface.deinit();
|
|
|
|
// If we have a font size we want, set that now
|
|
if (self.font_size) |size| {
|
|
self.core_surface.setFontSize(size);
|
|
}
|
|
|
|
// Note we're realized
|
|
self.realized = true;
|
|
}
|
|
|
|
pub fn deinit(self: *Surface) void {
|
|
// We don't allocate anything if we aren't realized.
|
|
if (!self.realized) return;
|
|
|
|
// 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;
|
|
|
|
// Free all our GTK stuff
|
|
c.g_object_unref(self.im_context);
|
|
c.g_value_unset(&self.clipboard);
|
|
}
|
|
|
|
fn render(self: *Surface) !void {
|
|
try self.core_surface.renderer.draw();
|
|
}
|
|
|
|
/// Invalidate the surface so that it forces a redraw on the next tick.
|
|
fn invalidate(self: *Surface) void {
|
|
c.gtk_gl_area_queue_render(self.gl_area);
|
|
}
|
|
|
|
/// Close this surface.
|
|
pub fn close(self: *Surface, processActive: bool) void {
|
|
if (!processActive) {
|
|
self.window.closeSurface(self);
|
|
return;
|
|
}
|
|
|
|
// Setup our basic message
|
|
const alert = c.gtk_message_dialog_new(
|
|
self.window.window,
|
|
c.GTK_DIALOG_MODAL,
|
|
c.GTK_MESSAGE_QUESTION,
|
|
c.GTK_BUTTONS_YES_NO,
|
|
"Close this terminal?",
|
|
);
|
|
c.gtk_message_dialog_format_secondary_text(
|
|
@ptrCast(alert),
|
|
"There is still a running process in the terminal. " ++
|
|
"Closing the terminal will kill this process. " ++
|
|
"Are you sure you want to close the terminal?\n\n" ++
|
|
"Click 'No' to cancel and return to your terminal.",
|
|
);
|
|
|
|
// We want the "yes" to appear destructive.
|
|
const yes_widget = c.gtk_dialog_get_widget_for_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_YES,
|
|
);
|
|
c.gtk_widget_add_css_class(yes_widget, "destructive-action");
|
|
|
|
// We want the "no" to be the default action
|
|
c.gtk_dialog_set_default_response(
|
|
@ptrCast(alert),
|
|
c.GTK_RESPONSE_NO,
|
|
);
|
|
|
|
_ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kCloseConfirmation), self, null, G_CONNECT_DEFAULT);
|
|
|
|
c.gtk_widget_show(alert);
|
|
}
|
|
|
|
pub fn toggleFullscreen(self: *Surface, mac_non_native: bool) void {
|
|
self.window.toggleFullscreen(mac_non_native);
|
|
}
|
|
|
|
pub fn newTab(self: *Surface) !void {
|
|
try self.window.newTab(&self.core_surface);
|
|
}
|
|
|
|
pub fn gotoPreviousTab(self: *Surface) void {
|
|
self.window.gotoPreviousTab(self);
|
|
}
|
|
|
|
pub fn gotoNextTab(self: *Surface) void {
|
|
self.window.gotoNextTab(self);
|
|
}
|
|
|
|
pub fn gotoTab(self: *Surface, n: usize) void {
|
|
self.window.gotoTab(n);
|
|
}
|
|
|
|
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 {
|
|
_ = self;
|
|
const monitor = glfw.Monitor.getPrimary() orelse return error.NoMonitor;
|
|
const scale = monitor.getContentScale();
|
|
return apprt.ContentScale{ .x = scale.x_scale, .y = scale.y_scale };
|
|
}
|
|
|
|
pub fn getSize(self: *const Surface) !apprt.SurfaceSize {
|
|
return self.size;
|
|
}
|
|
|
|
pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
|
|
_ = self;
|
|
_ = min;
|
|
_ = max_;
|
|
}
|
|
|
|
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
|
|
switch (self.title) {
|
|
.none => {},
|
|
|
|
.label => |label| {
|
|
c.gtk_label_set_text(label, slice.ptr);
|
|
},
|
|
}
|
|
|
|
// const root = c.gtk_widget_get_root(@ptrCast(
|
|
// *c.GtkWidget,
|
|
// self.gl_area,
|
|
// ));
|
|
}
|
|
|
|
pub fn getClipboardString(
|
|
self: *Surface,
|
|
clipboard_type: apprt.Clipboard,
|
|
) ![:0]const u8 {
|
|
const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type);
|
|
const content = c.gdk_clipboard_get_content(clipboard) orelse {
|
|
// On my machine, this NEVER works, so we fallback to glfw's
|
|
// implementation... I believe this never works because we need to
|
|
// use the async mechanism with GTK but that doesn't play nice
|
|
// with what our core expects.
|
|
log.debug("no GTK clipboard contents, falling back to glfw", .{});
|
|
return switch (clipboard_type) {
|
|
.standard => glfw.getClipboardString() orelse glfw.mustGetErrorCode(),
|
|
.selection => value: {
|
|
const raw = glfw_native.getX11SelectionString() orelse
|
|
return glfw.mustGetErrorCode();
|
|
break :value std.mem.span(raw);
|
|
},
|
|
};
|
|
};
|
|
|
|
c.g_value_unset(&self.clipboard);
|
|
_ = c.g_value_init(&self.clipboard, c.G_TYPE_STRING);
|
|
if (c.gdk_content_provider_get_value(content, &self.clipboard, null) == 0) {
|
|
return "";
|
|
}
|
|
|
|
const ptr = c.g_value_get_string(&self.clipboard);
|
|
return std.mem.sliceTo(ptr, 0);
|
|
}
|
|
|
|
pub fn setClipboardString(
|
|
self: *const Surface,
|
|
val: [:0]const u8,
|
|
clipboard_type: apprt.Clipboard,
|
|
) !void {
|
|
const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type);
|
|
c.gdk_clipboard_set_text(clipboard, val.ptr);
|
|
}
|
|
|
|
fn getClipboard(widget: *c.GtkWidget, clipboard: apprt.Clipboard) ?*c.GdkClipboard {
|
|
return switch (clipboard) {
|
|
.standard => c.gtk_widget_get_clipboard(widget),
|
|
.selection => c.gtk_widget_get_primary_clipboard(widget),
|
|
};
|
|
}
|
|
|
|
pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
|
|
return self.cursor_pos;
|
|
}
|
|
|
|
fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
|
|
log.debug("gl surface realized", .{});
|
|
|
|
// We need to make the context current so we can call GL functions.
|
|
c.gtk_gl_area_make_current(area);
|
|
if (c.gtk_gl_area_get_error(area)) |err| {
|
|
log.err("surface failed to realize: {s}", .{err.*.message});
|
|
return;
|
|
}
|
|
|
|
// realize means that our OpenGL context is ready, so we can now
|
|
// initialize the core surface which will setup the renderer.
|
|
const self = userdataSelf(ud.?);
|
|
self.realize() catch |err| {
|
|
// TODO: we need to destroy the GL area here.
|
|
log.err("surface failed to realize: {}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
/// render signal
|
|
fn gtkRender(area: *c.GtkGLArea, ctx: *c.GdkGLContext, ud: ?*anyopaque) callconv(.C) c.gboolean {
|
|
_ = area;
|
|
_ = ctx;
|
|
|
|
const self = userdataSelf(ud.?);
|
|
self.render() catch |err| {
|
|
log.err("surface failed to render: {}", .{err});
|
|
return 0;
|
|
};
|
|
|
|
return 1;
|
|
}
|
|
|
|
/// render signal
|
|
fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
|
|
// Some debug output to help understand what GTK is telling us.
|
|
{
|
|
const scale_factor = scale: {
|
|
const widget = @as(*c.GtkWidget, @ptrCast(area));
|
|
break :scale c.gtk_widget_get_scale_factor(widget);
|
|
};
|
|
|
|
const window_scale_factor = scale: {
|
|
const window = @as(*c.GtkNative, @ptrCast(self.window.window));
|
|
const gdk_surface = c.gtk_native_get_surface(window);
|
|
break :scale c.gdk_surface_get_scale_factor(gdk_surface);
|
|
};
|
|
|
|
log.debug("gl resize width={} height={} scale={} window_scale={}", .{
|
|
width,
|
|
height,
|
|
scale_factor,
|
|
window_scale_factor,
|
|
});
|
|
}
|
|
|
|
self.size = .{
|
|
.width = @intCast(width),
|
|
.height = @intCast(height),
|
|
};
|
|
|
|
// Call the primary callback.
|
|
if (self.realized) {
|
|
self.core_surface.sizeCallback(self.size) catch |err| {
|
|
log.err("error in size callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
/// "destroy" signal for surface
|
|
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
|
|
_ = v;
|
|
log.debug("gl destroy", .{});
|
|
|
|
const self = userdataSelf(ud.?);
|
|
const alloc = self.app.core_app.alloc;
|
|
self.deinit();
|
|
alloc.destroy(self);
|
|
}
|
|
|
|
fn gtkMouseDown(
|
|
gesture: *c.GtkGestureClick,
|
|
_: c.gint,
|
|
_: c.gdouble,
|
|
_: c.gdouble,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture)));
|
|
|
|
// If we don't have focus, grab it.
|
|
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
|
|
if (c.gtk_widget_has_focus(gl_widget) == 0) {
|
|
_ = c.gtk_widget_grab_focus(gl_widget);
|
|
}
|
|
|
|
self.core_surface.mouseButtonCallback(.press, button, .{}) catch |err| {
|
|
log.err("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkMouseUp(
|
|
gesture: *c.GtkGestureClick,
|
|
_: c.gint,
|
|
_: c.gdouble,
|
|
_: c.gdouble,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const button = translateMouseButton(c.gtk_gesture_single_get_current_button(@ptrCast(gesture)));
|
|
const self = userdataSelf(ud.?);
|
|
self.core_surface.mouseButtonCallback(.release, button, .{}) catch |err| {
|
|
log.err("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkMouseMotion(
|
|
_: *c.GtkEventControllerMotion,
|
|
x: c.gdouble,
|
|
y: c.gdouble,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
self.cursor_pos = .{
|
|
.x = @max(@as(f32, 0), @as(f32, @floatCast(x))),
|
|
.y = @floatCast(y),
|
|
};
|
|
|
|
self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| {
|
|
log.err("error in cursor pos callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkMouseScroll(
|
|
_: *c.GtkEventControllerScroll,
|
|
x: c.gdouble,
|
|
y: c.gdouble,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
|
|
// GTK doesn't support any of the scroll mods.
|
|
const scroll_mods: input.ScrollMods = .{};
|
|
|
|
self.core_surface.scrollCallback(x, y * -1, scroll_mods) catch |err| {
|
|
log.err("error in scroll callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkKeyPressed(
|
|
ec_key: *c.GtkEventControllerKey,
|
|
keyval: c.guint,
|
|
keycode: c.guint,
|
|
gtk_mods: c.GdkModifierType,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) c.gboolean {
|
|
return if (keyEvent(.press, ec_key, keyval, keycode, gtk_mods, ud)) 1 else 0;
|
|
}
|
|
|
|
fn gtkKeyReleased(
|
|
ec_key: *c.GtkEventControllerKey,
|
|
keyval: c.guint,
|
|
keycode: c.guint,
|
|
state: c.GdkModifierType,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) c.gboolean {
|
|
return if (keyEvent(.release, ec_key, keyval, keycode, state, ud)) 1 else 0;
|
|
}
|
|
|
|
/// Key press event. This is where we do ALL of our key handling,
|
|
/// translation to keyboard layouts, dead key handling, etc. Key handling
|
|
/// is complicated so this comment will explain what's going on.
|
|
///
|
|
/// 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 press events, we run the keypress through the input method context
|
|
/// in order to determine if we're in a dead key state, completed unicode
|
|
/// char, etc. This all happens through various callbacks: preedit, commit,
|
|
/// etc. These inspect "in_keypress" if they have to and set some instance
|
|
/// state.
|
|
///
|
|
/// We then 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.
|
|
///
|
|
/// Finally, we can emit the keyCallback.
|
|
///
|
|
/// Note we ALSO have an IMContext attached directly to the widget
|
|
/// which can emit preedit and commit callbacks. But, if we're not
|
|
/// in a keypress, we let those automatically work.
|
|
fn keyEvent(
|
|
action: input.Action,
|
|
ec_key: *c.GtkEventControllerKey,
|
|
keyval: c.guint,
|
|
keycode: c.guint,
|
|
gtk_mods: c.GdkModifierType,
|
|
ud: ?*anyopaque,
|
|
) bool {
|
|
const self = userdataSelf(ud.?);
|
|
const mods = translateMods(gtk_mods);
|
|
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
|
|
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key));
|
|
|
|
// Get the unshifted unicode value of the keyval. This is used
|
|
// by the Kitty keyboard protocol.
|
|
const keyval_unicode_unshifted: u21 = unshifted: {
|
|
var n: c_int = undefined;
|
|
var keys: [*c]c.GdkKeymapKey = undefined;
|
|
var keyvals: [*c]c.guint = undefined;
|
|
if (c.gdk_display_map_keycode(
|
|
c.gdk_event_get_display(event),
|
|
keycode,
|
|
&keys,
|
|
&keyvals,
|
|
&n,
|
|
) == 0) break :unshifted 0;
|
|
|
|
defer c.g_free(keys);
|
|
defer c.g_free(keyvals);
|
|
for (keys[0..@intCast(n)], 0..) |key, i| {
|
|
if (key.group == 0 and key.level == 0) {
|
|
break :unshifted @intCast(c.gdk_keyval_to_unicode(keyvals[i]));
|
|
}
|
|
}
|
|
|
|
break :unshifted 0;
|
|
};
|
|
|
|
// We always reset our committed text when ending a keypress so that
|
|
// future keypresses don't think we have a commit event.
|
|
defer self.im_len = 0;
|
|
|
|
// We only want to send the event through the IM context if we're a press
|
|
if (action == .press or action == .repeat) {
|
|
// We mark that we're in a keypress event. We use this in our
|
|
// IM commit callback to determine if we need to send a char callback
|
|
// to the core surface or not.
|
|
self.in_keypress = true;
|
|
defer self.in_keypress = false;
|
|
|
|
// Pass the event through the IM controller to handle dead key states.
|
|
// Filter is true if the event was handled by the IM controller.
|
|
_ = c.gtk_im_context_filter_keypress(self.im_context, event) != 0;
|
|
|
|
// If this is a dead key, then we're composing a character and
|
|
// we need to set our proper preedit state.
|
|
if (self.im_composing) preedit: {
|
|
const text = self.im_buf[0..self.im_len];
|
|
const view = std.unicode.Utf8View.init(text) catch |err| {
|
|
log.warn("cannot build utf8 view over input: {}", .{err});
|
|
break :preedit;
|
|
};
|
|
var it = view.iterator();
|
|
|
|
const cp: u21 = it.nextCodepoint() orelse 0;
|
|
self.core_surface.preeditCallback(cp) catch |err| {
|
|
log.err("error in preedit callback err={}", .{err});
|
|
break :preedit;
|
|
};
|
|
} else {
|
|
// If we aren't composing, then we set our preedit to
|
|
// empty no matter what.
|
|
self.core_surface.preeditCallback(null) catch {};
|
|
}
|
|
}
|
|
|
|
// 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 .invalid;
|
|
|
|
// Get our consumed modifiers
|
|
const consumed_mods: input.Mods = consumed: {
|
|
const raw = c.gdk_key_event_get_consumed_modifiers(event);
|
|
const masked = raw & c.GDK_MODIFIER_MASK;
|
|
break :consumed translateMods(masked);
|
|
};
|
|
|
|
// If we're not in a dead key state, we want to translate our text
|
|
// to some input.Key.
|
|
const key = if (!self.im_composing) key: {
|
|
// A completed key. If the length of the key is one then we can
|
|
// attempt to translate it to a key enum and call the key
|
|
// callback. First try plain ASCII.
|
|
if (self.im_len > 0) {
|
|
if (input.Key.fromASCII(self.im_buf[0])) |key| {
|
|
break :key key;
|
|
}
|
|
}
|
|
|
|
// If that doesn't work then we try to translate they kevval..
|
|
if (keyval_unicode != 0) {
|
|
if (std.math.cast(u8, keyval_unicode)) |byte| {
|
|
if (input.Key.fromASCII(byte)) |key| {
|
|
break :key key;
|
|
}
|
|
}
|
|
}
|
|
|
|
break :key physical_key;
|
|
} else .invalid;
|
|
|
|
// 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) {
|
|
if (std.math.cast(u21, keyval_unicode)) |cp| {
|
|
if (std.unicode.utf8Encode(cp, &self.im_buf)) |len| {
|
|
self.im_len = len;
|
|
} else |_| {}
|
|
}
|
|
}
|
|
|
|
// Invoke the core Ghostty logic to handle this input.
|
|
const consumed = self.core_surface.keyCallback(.{
|
|
.action = action,
|
|
.key = key,
|
|
.physical_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;
|
|
};
|
|
|
|
// If we consume the key then we want to reset the dead key state.
|
|
if (consumed and (action == .press or action == .repeat)) {
|
|
c.gtk_im_context_reset(self.im_context);
|
|
self.core_surface.preeditCallback(null) catch {};
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
fn gtkInputPreeditStart(
|
|
_: *c.GtkIMContext,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
//log.debug("preedit start", .{});
|
|
const self = userdataSelf(ud.?);
|
|
if (!self.in_keypress) return;
|
|
|
|
// Mark that we are now composing a string with a dead key state.
|
|
// We'll record the string in the preedit-changed callback.
|
|
self.im_composing = true;
|
|
}
|
|
|
|
fn gtkInputPreeditChanged(
|
|
ctx: *c.GtkIMContext,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
if (!self.in_keypress) return;
|
|
|
|
// Get our pre-edit string that we'll use to show the user.
|
|
var buf: [*c]u8 = undefined;
|
|
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
|
|
defer c.g_free(buf);
|
|
const str = std.mem.sliceTo(buf, 0);
|
|
|
|
// Copy the preedit string into the im_buf. This is safe because
|
|
// commit will always overwrite this.
|
|
self.im_len = @intCast(@min(self.im_buf.len, str.len));
|
|
@memcpy(self.im_buf[0..self.im_len], str);
|
|
}
|
|
|
|
fn gtkInputPreeditEnd(
|
|
_: *c.GtkIMContext,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
//log.debug("preedit end", .{});
|
|
const self = userdataSelf(ud.?);
|
|
if (!self.in_keypress) return;
|
|
self.im_composing = false;
|
|
self.im_len = 0;
|
|
}
|
|
|
|
fn gtkInputCommit(
|
|
_: *c.GtkIMContext,
|
|
bytes: [*:0]u8,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
const str = std.mem.sliceTo(bytes, 0);
|
|
|
|
// If we're in a key event, then we want to buffer the commit so
|
|
// that we can send the proper keycallback followed by the char
|
|
// callback.
|
|
if (self.in_keypress) {
|
|
if (str.len <= self.im_buf.len) {
|
|
@memcpy(self.im_buf[0..str.len], str);
|
|
self.im_len = @intCast(str.len);
|
|
|
|
// log.debug("input commit: {x}", .{self.im_buf[0]});
|
|
} else {
|
|
log.warn("not enough buffer space for input method commit", .{});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// We're not in a keypress, so this was sent from an on-screen emoji
|
|
// keyboard or someting like that. Send the characters directly to
|
|
// the surface.
|
|
_ = self.core_surface.keyCallback(.{
|
|
.action = .press,
|
|
.key = .invalid,
|
|
.physical_key = .invalid,
|
|
.mods = .{},
|
|
.consumed_mods = .{},
|
|
.composing = false,
|
|
.utf8 = str,
|
|
}) catch |err| {
|
|
log.err("error in key callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
self.core_surface.focusCallback(true) catch |err| {
|
|
log.err("error in focus callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
|
|
const self = userdataSelf(ud.?);
|
|
self.core_surface.focusCallback(false) catch |err| {
|
|
log.err("error in focus callback err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn gtkCloseConfirmation(
|
|
alert: *c.GtkMessageDialog,
|
|
response: c.gint,
|
|
ud: ?*anyopaque,
|
|
) callconv(.C) void {
|
|
c.gtk_window_destroy(@ptrCast(alert));
|
|
if (response == c.GTK_RESPONSE_YES) {
|
|
const self = userdataSelf(ud.?);
|
|
self.window.closeSurface(self);
|
|
}
|
|
}
|
|
|
|
fn userdataSelf(ud: *anyopaque) *Surface {
|
|
return @ptrCast(@alignCast(ud));
|
|
}
|
|
};
|
|
|
|
fn translateMouseButton(button: c.guint) 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,
|
|
};
|
|
}
|
|
|
|
fn translateMods(state: c.GdkModifierType) input.Mods {
|
|
var mods: input.Mods = .{};
|
|
if (state & c.GDK_SHIFT_MASK != 0) mods.shift = true;
|
|
if (state & c.GDK_CONTROL_MASK != 0) mods.ctrl = true;
|
|
if (state & c.GDK_ALT_MASK != 0) mods.alt = true;
|
|
if (state & c.GDK_SUPER_MASK != 0) mods.super = true;
|
|
|
|
// Lock is dependent on the X settings but we just assume caps lock.
|
|
if (state & c.GDK_LOCK_MASK != 0) mods.caps_lock = true;
|
|
return mods;
|
|
}
|
|
|
|
fn translateKey(keyval: c.guint) input.Key {
|
|
return switch (keyval) {
|
|
c.GDK_KEY_a => .a,
|
|
c.GDK_KEY_b => .b,
|
|
c.GDK_KEY_c => .c,
|
|
c.GDK_KEY_d => .d,
|
|
c.GDK_KEY_e => .e,
|
|
c.GDK_KEY_f => .f,
|
|
c.GDK_KEY_g => .g,
|
|
c.GDK_KEY_h => .h,
|
|
c.GDK_KEY_i => .i,
|
|
c.GDK_KEY_j => .j,
|
|
c.GDK_KEY_k => .k,
|
|
c.GDK_KEY_l => .l,
|
|
c.GDK_KEY_m => .m,
|
|
c.GDK_KEY_n => .n,
|
|
c.GDK_KEY_o => .o,
|
|
c.GDK_KEY_p => .p,
|
|
c.GDK_KEY_q => .q,
|
|
c.GDK_KEY_r => .r,
|
|
c.GDK_KEY_s => .s,
|
|
c.GDK_KEY_t => .t,
|
|
c.GDK_KEY_u => .u,
|
|
c.GDK_KEY_v => .v,
|
|
c.GDK_KEY_w => .w,
|
|
c.GDK_KEY_x => .x,
|
|
c.GDK_KEY_y => .y,
|
|
c.GDK_KEY_z => .z,
|
|
|
|
c.GDK_KEY_0 => .zero,
|
|
c.GDK_KEY_1 => .one,
|
|
c.GDK_KEY_2 => .two,
|
|
c.GDK_KEY_3 => .three,
|
|
c.GDK_KEY_4 => .four,
|
|
c.GDK_KEY_5 => .five,
|
|
c.GDK_KEY_6 => .six,
|
|
c.GDK_KEY_7 => .seven,
|
|
c.GDK_KEY_8 => .eight,
|
|
c.GDK_KEY_9 => .nine,
|
|
|
|
c.GDK_KEY_semicolon => .semicolon,
|
|
c.GDK_KEY_space => .space,
|
|
c.GDK_KEY_apostrophe => .apostrophe,
|
|
c.GDK_KEY_comma => .comma,
|
|
c.GDK_KEY_grave => .grave_accent, // `
|
|
c.GDK_KEY_period => .period,
|
|
c.GDK_KEY_slash => .slash,
|
|
c.GDK_KEY_minus => .minus,
|
|
c.GDK_KEY_equal => .equal,
|
|
c.GDK_KEY_bracketleft => .left_bracket, // [
|
|
c.GDK_KEY_bracketright => .right_bracket, // ]
|
|
c.GDK_KEY_backslash => .backslash, // /
|
|
|
|
c.GDK_KEY_Up => .up,
|
|
c.GDK_KEY_Down => .down,
|
|
c.GDK_KEY_Right => .right,
|
|
c.GDK_KEY_Left => .left,
|
|
c.GDK_KEY_Home => .home,
|
|
c.GDK_KEY_End => .end,
|
|
c.GDK_KEY_Insert => .insert,
|
|
c.GDK_KEY_Delete => .delete,
|
|
c.GDK_KEY_Caps_Lock => .caps_lock,
|
|
c.GDK_KEY_Scroll_Lock => .scroll_lock,
|
|
c.GDK_KEY_Num_Lock => .num_lock,
|
|
c.GDK_KEY_Page_Up => .page_up,
|
|
c.GDK_KEY_Page_Down => .page_down,
|
|
c.GDK_KEY_Escape => .escape,
|
|
c.GDK_KEY_Return => .enter,
|
|
c.GDK_KEY_Tab => .tab,
|
|
c.GDK_KEY_BackSpace => .backspace,
|
|
c.GDK_KEY_Print => .print_screen,
|
|
c.GDK_KEY_Pause => .pause,
|
|
|
|
c.GDK_KEY_F1 => .f1,
|
|
c.GDK_KEY_F2 => .f2,
|
|
c.GDK_KEY_F3 => .f3,
|
|
c.GDK_KEY_F4 => .f4,
|
|
c.GDK_KEY_F5 => .f5,
|
|
c.GDK_KEY_F6 => .f6,
|
|
c.GDK_KEY_F7 => .f7,
|
|
c.GDK_KEY_F8 => .f8,
|
|
c.GDK_KEY_F9 => .f9,
|
|
c.GDK_KEY_F10 => .f10,
|
|
c.GDK_KEY_F11 => .f11,
|
|
c.GDK_KEY_F12 => .f12,
|
|
c.GDK_KEY_F13 => .f13,
|
|
c.GDK_KEY_F14 => .f14,
|
|
c.GDK_KEY_F15 => .f15,
|
|
c.GDK_KEY_F16 => .f16,
|
|
c.GDK_KEY_F17 => .f17,
|
|
c.GDK_KEY_F18 => .f18,
|
|
c.GDK_KEY_F19 => .f19,
|
|
c.GDK_KEY_F20 => .f20,
|
|
c.GDK_KEY_F21 => .f21,
|
|
c.GDK_KEY_F22 => .f22,
|
|
c.GDK_KEY_F23 => .f23,
|
|
c.GDK_KEY_F24 => .f24,
|
|
c.GDK_KEY_F25 => .f25,
|
|
|
|
c.GDK_KEY_KP_0 => .kp_0,
|
|
c.GDK_KEY_KP_1 => .kp_1,
|
|
c.GDK_KEY_KP_2 => .kp_2,
|
|
c.GDK_KEY_KP_3 => .kp_3,
|
|
c.GDK_KEY_KP_4 => .kp_4,
|
|
c.GDK_KEY_KP_5 => .kp_5,
|
|
c.GDK_KEY_KP_6 => .kp_6,
|
|
c.GDK_KEY_KP_7 => .kp_7,
|
|
c.GDK_KEY_KP_8 => .kp_8,
|
|
c.GDK_KEY_KP_9 => .kp_9,
|
|
c.GDK_KEY_KP_Decimal => .kp_decimal,
|
|
c.GDK_KEY_KP_Divide => .kp_divide,
|
|
c.GDK_KEY_KP_Multiply => .kp_multiply,
|
|
c.GDK_KEY_KP_Subtract => .kp_subtract,
|
|
c.GDK_KEY_KP_Add => .kp_add,
|
|
c.GDK_KEY_KP_Enter => .kp_enter,
|
|
c.GDK_KEY_KP_Equal => .kp_equal,
|
|
|
|
c.GDK_KEY_Shift_L => .left_shift,
|
|
c.GDK_KEY_Control_L => .left_control,
|
|
c.GDK_KEY_Alt_L => .left_alt,
|
|
c.GDK_KEY_Super_L => .left_super,
|
|
c.GDK_KEY_Shift_R => .right_shift,
|
|
c.GDK_KEY_Control_R => .right_control,
|
|
c.GDK_KEY_Alt_R => .right_alt,
|
|
c.GDK_KEY_Super_R => .right_super,
|
|
|
|
else => .invalid,
|
|
};
|
|
}
|