diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 8c5adef41..f4e4c74a8 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -1,1801 +1,8 @@ //! 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 terminal = @import("../terminal/main.zig"); -const CoreApp = @import("../App.zig"); -const CoreSurface = @import("../Surface.zig"); -const configpkg = @import("../config.zig"); -const Config = configpkg.Config; +pub const App = @import("gtk/App.zig"); +pub const Surface = @import("gtk/Surface.zig"); -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, - - /// The "none" cursor. We use one that is shared across the entire app. - cursor_none: ?*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(); - - // If we had configuration errors, then log them. - if (!config._errors.empty()) { - for (config._errors.list.items) |err| { - log.warn("configuration error: {s}", .{err.message}); - } - } - - // The "none" cursor is used for hiding the cursor - const cursor_none = c.gdk_cursor_new_from_name("none", null); - errdefer if (cursor_none) |cursor| c.g_object_unref(cursor); - - // 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: ?[*c]const u8 = uniqueness_id: { - if (!config.@"gtk-single-instance") break :uniqueness_id null; - - break :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 orelse null, - - // 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); - - return .{ - .core_app = core_app, - .app = app, - .config = config, - .ctx = ctx, - .cursor_none = cursor_none, - - // 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); - - if (self.cursor_none) |cursor| c.g_object_unref(cursor); - - 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 the app says we don't need to confirm, then we can quit now. - if (!self.core_app.needsConfirmQuit()) { - self.quitNow(); - return; - } - - // 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 quitNow(self: *App) void { - _ = self; - 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); - } - - fn gtkQuitConfirmation( - alert: *c.GtkMessageDialog, - response: c.gint, - ud: ?*anyopaque, - ) callconv(.C) void { - const self: *App = @ptrCast(@alignCast(ud orelse return)); - - // 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 - self.quitNow(); - } - - /// 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"); - } - - // Hide window decoration if configured. This has to happen before - // `gtk_widget_show`. - if (!app.config.@"window-decoration") { - c.gtk_window_set_decorated(gtk_window, 0); - } - - 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); - - // This is important so the notebook expands to fit available space. - // Otherwise, it will be zero/zero in the box below. - c.gtk_widget_set_vexpand(notebook_widget, 1); - c.gtk_widget_set_hexpand(notebook_widget, 1); - - // 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); - _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), self, null, G_CONNECT_DEFAULT); - - // Create our box which will hold our widgets. - const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); - - // In debug we show a warning. This is a really common issue where - // people build from source in debug and performance is really bad. - if (builtin.mode == .Debug) { - const warning = c.gtk_label_new("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); - c.gtk_widget_set_margin_top(warning, 10); - c.gtk_widget_set_margin_bottom(warning, 10); - c.gtk_box_append(@ptrCast(box), warning); - } - c.gtk_box_append(@ptrCast(box), notebook_widget); - - // The box is our main child - c.gtk_window_set_child(gtk_window, box); - } - - 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, _: configpkg.NonNativeFullscreen) 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 gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { - const self = userdataSelf(ud.?); - const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); - const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); - const label_text = c.gtk_label_get_text(gtk_label); - c.gtk_window_set_title(self.window, label_text); - } - - fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { - _ = v; - log.debug("window close request", .{}); - const self = userdataSelf(ud.?); - - // If none of our surfaces need confirmation, we can just exit. - for (self.app.core_app.surfaces.items) |surface| { - if (surface.window == self) { - if (surface.core_surface.needsConfirmQuit()) break; - } - } else { - c.gtk_window_destroy(self.window); - return true; - } - - // 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, - - /// Any active cursor we may have - cursor: ?*c.GdkCursor = null, - - /// 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); - - // 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, - self.app, - 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); - - if (self.cursor) |cursor| c.g_object_unref(cursor); - } - - 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: configpkg.NonNativeFullscreen) 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 widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); - if (c.gtk_widget_is_focus(widget) == 1) { - c.gtk_window_set_title(self.window.window, c.gtk_label_get_text(label)); - } - }, - } - - // const root = c.gtk_widget_get_root(@ptrCast( - // *c.GtkWidget, - // self.gl_area, - // )); - } - - 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 = c.gdk_cursor_new_from_name(name.ptr, null) orelse { - log.warn("unsupported cursor name={s}", .{name}); - return; - }; - errdefer c.g_object_unref(cursor); - - // Set our new cursor - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); - - // Free our existing cursor - if (self.cursor) |old| c.g_object_unref(old); - 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. - - if (visible) { - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.cursor); - return; - } - - // Set our new cursor to the app "none" cursor - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); - } - - 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); - } - - /// Scale x/y by the GDK device scale. - fn scaledCoordinates( - self: *const Surface, - x: c.gdouble, - y: c.gdouble, - ) struct { - x: c.gdouble, - y: c.gdouble, - } { - const scale_factor: f64 = @floatFromInt( - c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area)), - ); - - return .{ - .x = x * scale_factor, - .y = y * scale_factor, - }; - } - - 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.?); - const scaled = self.scaledCoordinates(x, y); - - self.cursor_pos = .{ - .x = @floatCast(@max(0, scaled.x)), - .y = @floatCast(scaled.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.?); - const scaled = self.scaledCoordinates(x, y); - - // GTK doesn't support any of the scroll mods. - const scroll_mods: input.ScrollMods = .{}; - - self.core_surface.scrollCallback( - scaled.x, - scaled.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, - }; +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig new file mode 100644 index 000000000..fba7bf89e --- /dev/null +++ b/src/apprt/gtk/App.zig @@ -0,0 +1,298 @@ +/// 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. +/// +/// In GTK, the App contains the primary GApplication and GMainContext +/// (event loop) along with any global app state. +const App = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const glfw = @import("glfw"); +const configpkg = @import("../../config.zig"); +const Config = configpkg.Config; +const CoreApp = @import("../../App.zig"); +const CoreSurface = @import("../../Surface.zig"); + +const Surface = @import("Surface.zig"); +const Window = @import("Window.zig"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +pub const Options = struct {}; + +core_app: *CoreApp, +config: Config, + +app: *c.GtkApplication, +ctx: *c.GMainContext, + +/// The "none" cursor. We use one that is shared across the entire app. +cursor_none: ?*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(); + + // If we had configuration errors, then log them. + if (!config._errors.empty()) { + for (config._errors.list.items) |err| { + log.warn("configuration error: {s}", .{err.message}); + } + } + + // The "none" cursor is used for hiding the cursor + const cursor_none = c.gdk_cursor_new_from_name("none", null); + errdefer if (cursor_none) |cursor| c.g_object_unref(cursor); + + // 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: ?[*c]const u8 = uniqueness_id: { + if (!config.@"gtk-single-instance") break :uniqueness_id null; + + break :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 orelse null, + c.G_APPLICATION_DEFAULT_FLAGS, + ))) orelse return error.GtkInitFailed; + errdefer c.g_object_unref(app); + _ = c.g_signal_connect_data( + app, + "activate", + c.G_CALLBACK(&activate), + core_app, + null, + c.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); + + return .{ + .core_app = core_app, + .app = app, + .config = config, + .ctx = ctx, + .cursor_none = cursor_none, + + // 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); + + if (self.cursor_none) |cursor| c.g_object_unref(cursor); + + 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; +} + +/// Called by CoreApp to wake up the event loop. +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.redraw(); +} + +/// Called by CoreApp to create a new window with a new surface. +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 Window.create(alloc, 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 the app says we don't need to confirm, then we can quit now. + if (!self.core_app.needsConfirmQuit()) { + self.quitNow(); + return; + } + + // 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, + c.G_CONNECT_DEFAULT, + ); + + c.gtk_widget_show(alert); +} + +/// This immediately destroys all windows, forcing the application to quit. +fn quitNow(self: *App) void { + _ = self; + 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); +} + +fn gtkQuitConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *App = @ptrCast(@alignCast(ud orelse return)); + + // 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 + self.quitNow(); +} + +/// 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 = {} }); +} diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig new file mode 100644 index 000000000..398f2bf85 --- /dev/null +++ b/src/apprt/gtk/Surface.zig @@ -0,0 +1,1140 @@ +/// 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 glfw = @import("glfw"); +const configpkg = @import("../../config.zig"); +const apprt = @import("../../apprt.zig"); +const font = @import("../../font/main.zig"); +const input = @import("../../input.zig"); +const terminal = @import("../../terminal/main.zig"); +const CoreSurface = @import("../../Surface.zig"); + +const App = @import("App.zig"); +const Window = @import("Window.zig"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +// We need native X11 access to access the primary clipboard. +const glfw_native = glfw.Native(.{ .x11 = true }); + +/// 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, + +/// Any active cursor we may have +cursor: ?*c.GdkCursor = null, + +/// 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); + + // 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, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(opts.gl_area, "unrealize", c.G_CALLBACK(>kUnrealize), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(opts.gl_area, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(opts.gl_area, "render", c.G_CALLBACK(>kRender), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(opts.gl_area, "resize", c.G_CALLBACK(>kResize), self, null, c.G_CONNECT_DEFAULT); + + _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(>kKeyPressed), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(ec_key_press, "key-released", c.G_CALLBACK(>kKeyReleased), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(ec_focus, "enter", c.G_CALLBACK(>kFocusEnter), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(ec_focus, "leave", c.G_CALLBACK(>kFocusLeave), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_click, "released", c.G_CALLBACK(>kMouseUp), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(ec_motion, "motion", c.G_CALLBACK(>kMouseMotion), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(ec_scroll, "scroll", c.G_CALLBACK(>kMouseScroll), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-start", c.G_CALLBACK(>kInputPreeditStart), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(>kInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(>kInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, c.G_CONNECT_DEFAULT); +} + +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 + try self.core_surface.renderer.displayRealize(); + 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(); + + // 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| { + 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); + + if (self.cursor) |cursor| c.g_object_unref(cursor); +} + +fn render(self: *Surface) !void { + try self.core_surface.renderer.draw(); +} + +/// Invalidate the surface so that it forces a redraw on the next tick. +pub fn redraw(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, c.G_CONNECT_DEFAULT); + + c.gtk_widget_show(alert); +} + +pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) 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 widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); + if (c.gtk_widget_is_focus(widget) == 1) { + c.gtk_window_set_title(self.window.window, c.gtk_label_get_text(label)); + } + }, + } + + // const root = c.gtk_widget_get_root(@ptrCast( + // *c.GtkWidget, + // self.gl_area, + // )); +} + +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 = c.gdk_cursor_new_from_name(name.ptr, null) orelse { + log.warn("unsupported cursor name={s}", .{name}); + return; + }; + errdefer c.g_object_unref(cursor); + + // Set our new cursor + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); + + // Free our existing cursor + if (self.cursor) |old| c.g_object_unref(old); + 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. + + if (visible) { + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.cursor); + return; + } + + // Set our new cursor to the app "none" cursor + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); +} + +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; + }; +} + +/// This is called when the underlying OpenGL resources must be released. +/// This is usually due to the OpenGL area changing GDK surfaces. +fn gtkUnrealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { + _ = area; + + const self = userdataSelf(ud.?); + self.core_surface.renderer.displayUnrealized(); +} + +/// 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); +} + +/// Scale x/y by the GDK device scale. +fn scaledCoordinates( + self: *const Surface, + x: c.gdouble, + y: c.gdouble, +) struct { + x: c.gdouble, + y: c.gdouble, +} { + const scale_factor: f64 = @floatFromInt( + c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area)), + ); + + return .{ + .x = x * scale_factor, + .y = y * scale_factor, + }; +} + +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.?); + const scaled = self.scaledCoordinates(x, y); + + self.cursor_pos = .{ + .x = @floatCast(@max(0, scaled.x)), + .y = @floatCast(scaled.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.?); + const scaled = self.scaledCoordinates(x, y); + + // GTK doesn't support any of the scroll mods. + const scroll_mods: input.ScrollMods = .{}; + + self.core_surface.scrollCallback( + scaled.x, + scaled.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, + }; +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig new file mode 100644 index 000000000..c336c2e59 --- /dev/null +++ b/src/apprt/gtk/Window.zig @@ -0,0 +1,460 @@ +/// A Window is a single, real GTK window. +const Window = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const configpkg = @import("../../config.zig"); +const font = @import("../../font/main.zig"); +const CoreSurface = @import("../../Surface.zig"); + +const App = @import("App.zig"); +const Surface = @import("Surface.zig"); +const c = @import("c.zig"); + +const log = std.log.scoped(.gtk); + +const GL_AREA_SURFACE = "gl_area_surface"; + +app: *App, + +/// Our window +window: *c.GtkWindow, + +/// The notebook (tab grouping) for this window. +notebook: *c.GtkNotebook, + +/// The resources directory for the icon (if any). We need to retain a +/// pointer to this because GTK can use it at any time. +icon_search_dir: ?[:0]const u8 = null, + +pub fn create(alloc: Allocator, app: *App) !*Window { + // 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(app); + return window; +} + +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"); + } + + // Hide window decoration if configured. This has to happen before + // `gtk_widget_show`. + if (!app.config.@"window-decoration") { + c.gtk_window_set_decorated(gtk_window, 0); + } + + // 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); + + // This is important so the notebook expands to fit available space. + // Otherwise, it will be zero/zero in the box below. + c.gtk_widget_set_vexpand(notebook_widget, 1); + c.gtk_widget_set_hexpand(notebook_widget, 1); + + // 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); + + // Create our box which will hold our widgets. + const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); + + // In debug we show a warning. This is a really common issue where + // people build from source in debug and performance is really bad. + if (builtin.mode == .Debug) { + const warning = c.gtk_label_new("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); + c.gtk_widget_set_margin_top(warning, 10); + c.gtk_widget_set_margin_bottom(warning, 10); + c.gtk_box_append(@ptrCast(box), warning); + } + c.gtk_box_append(@ptrCast(box), notebook_widget); + + // All of our events + _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(notebook_add_btn, "clicked", c.G_CALLBACK(>kTabAddClick), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), self, null, c.G_CONNECT_DEFAULT); + + // The box is our main child + c.gtk_window_set_child(gtk_window, box); + + // Show the window + c.gtk_widget_show(window); +} + +pub fn deinit(self: *Window) void { + 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), surface, null, c.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); + c.gtk_notebook_set_tab_detachable(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. + c.g_object_set_data(@ptrCast(gl_area), GL_AREA_SURFACE, surface); + + // 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. +pub fn closeSurface(self: *Window, surface: *Surface) void { + assert(surface.window == self); + + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(surface.gl_area)) orelse return; + self.closeTab(page); +} + +/// Go to the previous tab for a surface. +pub fn gotoPreviousTab(self: *Window, surface: *Surface) void { + const page = c.gtk_notebook_get_page(self.notebook, @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. +pub fn gotoNextTab(self: *Window, surface: *Surface) void { + const page = c.gtk_notebook_get_page(self.notebook, @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. +pub 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. +pub fn toggleFullscreen(self: *Window, _: configpkg.NonNativeFullscreen) 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(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { + const surface: *Surface = @ptrCast(@alignCast(ud)); + surface.core_surface.close(); +} + +fn gtkPageAdded( + _: *c.GtkNotebook, + child: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud.?); + _ = self; + _ = child; +} + +fn gtkPageRemoved( + _: *c.GtkNotebook, + _: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud.?); + + // Hide the tab bar if we only have one tab after removal + const remaining = c.gtk_notebook_get_n_pages(self.notebook); + if (remaining == 1) { + c.gtk_notebook_set_show_tabs(self.notebook, 0); + } +} + +fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { + const self = userdataSelf(ud.?); + const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); + const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); + const label_text = c.gtk_label_get_text(gtk_label); + c.gtk_window_set_title(self.window, label_text); +} + +fn gtkNotebookCreateWindow( + _: *c.GtkNotebook, + page: *c.GtkWidget, + ud: ?*anyopaque, +) callconv(.C) ?*c.GtkNotebook { + // The surface for the page is stored in the widget data. + const surface: *Surface = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), GL_AREA_SURFACE) orelse return null, + )); + + const self = userdataSelf(ud.?); + const alloc = self.app.core_app.alloc; + + // Create a new window + const window = Window.create(alloc, self.app) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + + // We need to update our surface to point to the new window so that + // events such as new tab go to the right window. + surface.window = window; + + return window.notebook; +} + +fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { + _ = v; + log.debug("window close request", .{}); + const self = userdataSelf(ud.?); + + // If none of our surfaces need confirmation, we can just exit. + for (self.app.core_app.surfaces.items) |surface| { + if (surface.window == self) { + if (surface.core_surface.needsConfirmQuit()) break; + } + } else { + c.gtk_window_destroy(self.window); + return true; + } + + // 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, c.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); +} + +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)); +} diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig new file mode 100644 index 000000000..c5952d321 --- /dev/null +++ b/src/apprt/gtk/c.zig @@ -0,0 +1,11 @@ +const c = @cImport({ + @cInclude("gtk/gtk.h"); +}); + +pub usingnamespace c; + +/// Compatibility with gobject < 2.74 +pub usingnamespace if (@hasDecl(c, "G_CONNECT_DEFAULT")) struct {} else struct { + pub const G_CONNECT_DEFAULT = 0; + pub const G_APPLICATION_DEFAULT_FLAGS = c.G_APPLICATION_FLAGS_NONE; +}; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 8bd6e8004..4551472db 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -72,12 +72,7 @@ gl_cells_size: usize = 0, gl_cells_written: usize = 0, /// Shader program for cell rendering. -program: gl.Program, -vao: gl.VertexArray, -ebo: gl.Buffer, -vbo: gl.Buffer, -texture: gl.Texture, -texture_color: gl.Texture, +gl_state: ?GLState = null, /// The font structures. font_group: *font.GroupCache, @@ -110,6 +105,8 @@ const SetScreenSize = struct { size: renderer.ScreenSize, fn apply(self: SetScreenSize, r: *const OpenGL) !void { + const gl_state = r.gl_state orelse return error.OpenGLUninitialized; + // Apply our padding const padding = r.padding.explicit.add(if (r.padding.balance) renderer.Padding.balanced(self.size, r.gridSize(self.size), r.cell_size) @@ -135,7 +132,7 @@ const SetScreenSize = struct { ); // Update the projection uniform within our shader - try r.program.setUniform( + try gl_state.program.setUniform( "projection", // 2D orthographic projection with the full w/h @@ -153,18 +150,20 @@ const SetFontSize = struct { metrics: font.face.Metrics, fn apply(self: SetFontSize, r: *const OpenGL) !void { - try r.program.setUniform( + const gl_state = r.gl_state orelse return error.OpenGLUninitialized; + + try gl_state.program.setUniform( "cell_size", @Vector(2, f32){ @floatFromInt(self.metrics.cell_width), @floatFromInt(self.metrics.cell_height), }, ); - try r.program.setUniform( + try gl_state.program.setUniform( "strikethrough_position", @as(f32, @floatFromInt(self.metrics.strikethrough_position)), ); - try r.program.setUniform( + try gl_state.program.setUniform( "strikethrough_thickness", @as(f32, @floatFromInt(self.metrics.strikethrough_thickness)), ); @@ -295,12 +294,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { }); errdefer shaper.deinit(); - // Create our shader - const program = try gl.Program.createVF( - @embedFile("shaders/cell.v.glsl"), - @embedFile("shaders/cell.f.glsl"), - ); - // Setup our font metrics uniform const metrics = try resetFontMetrics( alloc, @@ -308,109 +301,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { options.config.font_thicken, ); - // Set our cell dimensions - const pbind = try program.use(); - defer pbind.unbind(); - - // Set all of our texture indexes - try program.setUniform("text", 0); - try program.setUniform("text_color", 1); - - // Setup our VAO - const vao = try gl.VertexArray.create(); - errdefer vao.destroy(); - try vao.bind(); - defer gl.VertexArray.unbind() catch null; - - // Element buffer (EBO) - const ebo = try gl.Buffer.create(); - errdefer ebo.destroy(); - var ebobind = try ebo.bind(.ElementArrayBuffer); - defer ebobind.unbind(); - try ebobind.setData([6]u8{ - 0, 1, 3, // Top-left triangle - 1, 2, 3, // Bottom-right triangle - }, .StaticDraw); - - // Vertex buffer (VBO) - const vbo = try gl.Buffer.create(); - errdefer vbo.destroy(); - var vbobind = try vbo.bind(.ArrayBuffer); - defer vbobind.unbind(); - var offset: usize = 0; - try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(GPUCell), offset); - offset += 2 * @sizeOf(u16); - try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(GPUCell), offset); - offset += 2 * @sizeOf(u32); - try vbobind.attributeAdvanced(2, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(GPUCell), offset); - offset += 2 * @sizeOf(u32); - try vbobind.attributeAdvanced(3, 2, gl.c.GL_INT, false, @sizeOf(GPUCell), offset); - offset += 2 * @sizeOf(i32); - try vbobind.attributeAdvanced(4, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset); - offset += 4 * @sizeOf(u8); - try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset); - offset += 4 * @sizeOf(u8); - try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset); - offset += 1 * @sizeOf(u8); - try vbobind.attributeIAdvanced(7, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset); - try vbobind.enableAttribArray(0); - try vbobind.enableAttribArray(1); - try vbobind.enableAttribArray(2); - try vbobind.enableAttribArray(3); - try vbobind.enableAttribArray(4); - try vbobind.enableAttribArray(5); - try vbobind.enableAttribArray(6); - try vbobind.enableAttribArray(7); - try vbobind.attributeDivisor(0, 1); - try vbobind.attributeDivisor(1, 1); - try vbobind.attributeDivisor(2, 1); - try vbobind.attributeDivisor(3, 1); - try vbobind.attributeDivisor(4, 1); - try vbobind.attributeDivisor(5, 1); - try vbobind.attributeDivisor(6, 1); - try vbobind.attributeDivisor(7, 1); - - // Build our texture - const tex = try gl.Texture.create(); - errdefer tex.destroy(); - { - const texbind = try tex.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .Red, - @intCast(options.font_group.atlas_greyscale.size), - @intCast(options.font_group.atlas_greyscale.size), - 0, - .Red, - .UnsignedByte, - options.font_group.atlas_greyscale.data.ptr, - ); - } - - // Build our color texture - const tex_color = try gl.Texture.create(); - errdefer tex_color.destroy(); - { - const texbind = try tex_color.bind(.@"2D"); - try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); - try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); - try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); - try texbind.image2D( - 0, - .RGBA, - @intCast(options.font_group.atlas_color.size), - @intCast(options.font_group.atlas_color.size), - 0, - .BGRA, - .UnsignedByte, - options.font_group.atlas_color.data.ptr, - ); - } + var gl_state = try GLState.init(options.font_group); + errdefer gl_state.deinit(); return OpenGL{ .alloc = alloc, @@ -420,12 +312,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { .cells_lru = CellsLRU.init(0), .cell_size = .{ .width = metrics.cell_width, .height = metrics.cell_height }, .screen_size = null, - .program = program, - .vao = vao, - .ebo = ebo, - .vbo = vbo, - .texture = tex, - .texture_color = tex_color, + .gl_state = gl_state, .font_group = options.font_group, .font_shaper = shaper, .draw_background = options.config.background, @@ -440,12 +327,7 @@ pub fn deinit(self: *OpenGL) void { self.font_shaper.deinit(); self.alloc.free(self.font_shaper.cell_buf); - self.texture.destroy(); - self.texture_color.destroy(); - self.vbo.destroy(); - self.ebo.destroy(); - self.vao.destroy(); - self.program.destroy(); + if (self.gl_state) |*v| v.deinit(); self.resetCellsLRU(); self.cells_lru.deinit(self.alloc); @@ -508,13 +390,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.glfw => try self.threadEnter(surface), } - // Blending for text. We use GL_ONE here because we should be using - // premultiplied alpha for all our colors in our fragment shaders. - // This avoids having a blurry border where transparency is expected on - // pixels. - try gl.enable(gl.c.GL_BLEND); - try gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA); - // These are very noisy so this is commented, but easy to uncomment // whenever we need to check the OpenGL extension list // if (builtin.mode == .Debug) { @@ -538,6 +413,54 @@ pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { } } +/// Called when the OpenGL context is made invalid, so we need to free +/// all previous resources and stop rendering. +pub fn displayUnrealized(self: *OpenGL) void { + if (single_threaded_draw) self.draw_mutex.lock(); + defer if (single_threaded_draw) self.draw_mutex.unlock(); + + if (self.gl_state) |*v| { + v.deinit(); + self.gl_state = null; + } +} + +/// Called when the OpenGL is ready to be initialized. +pub fn displayRealize(self: *OpenGL) !void { + if (single_threaded_draw) self.draw_mutex.lock(); + defer if (single_threaded_draw) self.draw_mutex.unlock(); + + // Reset our GPU uniforms + const metrics = try resetFontMetrics( + self.alloc, + self.font_group, + self.config.font_thicken, + ); + + // Make our new state + var gl_state = try GLState.init(self.font_group); + errdefer gl_state.deinit(); + + // Unrealize if we have to + if (self.gl_state) |*v| v.deinit(); + + // Set our new state + self.gl_state = gl_state; + + // Make sure we invalidate all the fields so that we + // reflush everything + self.gl_cells_size = 0; + self.gl_cells_written = 0; + self.font_group.atlas_greyscale.modified = true; + self.font_group.atlas_color.modified = true; + + // We need to reset our uniforms + if (self.screen_size) |size| { + self.deferred_screen_size = .{ .size = size }; + } + self.deferred_font_size = .{ .metrics = metrics }; +} + /// Callback called by renderer.Thread when it begins. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; @@ -1429,11 +1352,13 @@ pub fn setScreenSize( /// Updates the font texture atlas if it is dirty. fn flushAtlas(self: *OpenGL) !void { + const gl_state = self.gl_state orelse return; + { const atlas = &self.font_group.atlas_greyscale; if (atlas.modified) { atlas.modified = false; - var texbind = try self.texture.bind(.@"2D"); + var texbind = try gl_state.texture.bind(.@"2D"); defer texbind.unbind(); if (atlas.resized) { @@ -1467,7 +1392,7 @@ fn flushAtlas(self: *OpenGL) !void { const atlas = &self.font_group.atlas_color; if (atlas.modified) { atlas.modified = false; - var texbind = try self.texture_color.bind(.@"2D"); + var texbind = try gl_state.texture_color.bind(.@"2D"); defer texbind.unbind(); if (atlas.resized) { @@ -1507,6 +1432,7 @@ pub fn draw(self: *OpenGL) !void { // If we're in single-threaded more we grab a lock since we use shared data. if (single_threaded_draw) self.draw_mutex.lock(); defer if (single_threaded_draw) self.draw_mutex.unlock(); + const gl_state = self.gl_state orelse return; // If we have no cells to render, then we render nothing. if (self.cells.items.len == 0) return; @@ -1525,28 +1451,28 @@ pub fn draw(self: *OpenGL) !void { gl.clear(gl.c.GL_COLOR_BUFFER_BIT); // Setup our VAO - try self.vao.bind(); + try gl_state.vao.bind(); defer gl.VertexArray.unbind() catch null; // Bind EBO - var ebobind = try self.ebo.bind(.ElementArrayBuffer); + var ebobind = try gl_state.ebo.bind(.ElementArrayBuffer); defer ebobind.unbind(); // Bind VBO and set data - var binding = try self.vbo.bind(.ArrayBuffer); + var binding = try gl_state.vbo.bind(.ArrayBuffer); defer binding.unbind(); // Bind our textures try gl.Texture.active(gl.c.GL_TEXTURE0); - var texbind = try self.texture.bind(.@"2D"); + var texbind = try gl_state.texture.bind(.@"2D"); defer texbind.unbind(); try gl.Texture.active(gl.c.GL_TEXTURE1); - var texbind1 = try self.texture_color.bind(.@"2D"); + var texbind1 = try gl_state.texture_color.bind(.@"2D"); defer texbind1.unbind(); // Pick our shader to use - const pbind = try self.program.use(); + const pbind = try gl_state.program.use(); defer pbind.unbind(); // If we have deferred operations, run them. @@ -1596,7 +1522,7 @@ fn drawCells( // If we have data to write to the GPU, send it. if (self.gl_cells_written < cells.items.len) { const data = cells.items[self.gl_cells_written..]; - //log.info("sending {} cells to GPU", .{data.len}); + // log.info("sending {} cells to GPU", .{data.len}); try binding.setSubData(self.gl_cells_written * @sizeOf(GPUCell), data); self.gl_cells_written += data.len; @@ -1611,3 +1537,152 @@ fn drawCells( cells.items.len, ); } + +/// The OpenGL objects that are associated with a renderer. This makes it +/// easy to create/destroy these as a set in situations i.e. where the +/// OpenGL context is replaced. +const GLState = struct { + program: gl.Program, + vao: gl.VertexArray, + ebo: gl.Buffer, + vbo: gl.Buffer, + texture: gl.Texture, + texture_color: gl.Texture, + + pub fn init(font_group: *font.GroupCache) !GLState { + // Blending for text. We use GL_ONE here because we should be using + // premultiplied alpha for all our colors in our fragment shaders. + // This avoids having a blurry border where transparency is expected on + // pixels. + try gl.enable(gl.c.GL_BLEND); + try gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA); + + // Shader + const program = try gl.Program.createVF( + @embedFile("shaders/cell.v.glsl"), + @embedFile("shaders/cell.f.glsl"), + ); + + // Set our cell dimensions + const pbind = try program.use(); + defer pbind.unbind(); + + // Set all of our texture indexes + try program.setUniform("text", 0); + try program.setUniform("text_color", 1); + + // Setup our VAO + const vao = try gl.VertexArray.create(); + errdefer vao.destroy(); + try vao.bind(); + defer gl.VertexArray.unbind() catch null; + + // Element buffer (EBO) + const ebo = try gl.Buffer.create(); + errdefer ebo.destroy(); + var ebobind = try ebo.bind(.ElementArrayBuffer); + defer ebobind.unbind(); + try ebobind.setData([6]u8{ + 0, 1, 3, // Top-left triangle + 1, 2, 3, // Bottom-right triangle + }, .StaticDraw); + + // Vertex buffer (VBO) + const vbo = try gl.Buffer.create(); + errdefer vbo.destroy(); + var vbobind = try vbo.bind(.ArrayBuffer); + defer vbobind.unbind(); + var offset: usize = 0; + try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(GPUCell), offset); + offset += 2 * @sizeOf(u16); + try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(GPUCell), offset); + offset += 2 * @sizeOf(u32); + try vbobind.attributeAdvanced(2, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(GPUCell), offset); + offset += 2 * @sizeOf(u32); + try vbobind.attributeAdvanced(3, 2, gl.c.GL_INT, false, @sizeOf(GPUCell), offset); + offset += 2 * @sizeOf(i32); + try vbobind.attributeAdvanced(4, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset); + offset += 4 * @sizeOf(u8); + try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset); + offset += 4 * @sizeOf(u8); + try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset); + offset += 1 * @sizeOf(u8); + try vbobind.attributeIAdvanced(7, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset); + try vbobind.enableAttribArray(0); + try vbobind.enableAttribArray(1); + try vbobind.enableAttribArray(2); + try vbobind.enableAttribArray(3); + try vbobind.enableAttribArray(4); + try vbobind.enableAttribArray(5); + try vbobind.enableAttribArray(6); + try vbobind.enableAttribArray(7); + try vbobind.attributeDivisor(0, 1); + try vbobind.attributeDivisor(1, 1); + try vbobind.attributeDivisor(2, 1); + try vbobind.attributeDivisor(3, 1); + try vbobind.attributeDivisor(4, 1); + try vbobind.attributeDivisor(5, 1); + try vbobind.attributeDivisor(6, 1); + try vbobind.attributeDivisor(7, 1); + + // Build our texture + const tex = try gl.Texture.create(); + errdefer tex.destroy(); + { + const texbind = try tex.bind(.@"2D"); + try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); + try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); + try texbind.image2D( + 0, + .Red, + @intCast(font_group.atlas_greyscale.size), + @intCast(font_group.atlas_greyscale.size), + 0, + .Red, + .UnsignedByte, + font_group.atlas_greyscale.data.ptr, + ); + } + + // Build our color texture + const tex_color = try gl.Texture.create(); + errdefer tex_color.destroy(); + { + const texbind = try tex_color.bind(.@"2D"); + try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE); + try texbind.parameter(.MinFilter, gl.c.GL_LINEAR); + try texbind.parameter(.MagFilter, gl.c.GL_LINEAR); + try texbind.image2D( + 0, + .RGBA, + @intCast(font_group.atlas_color.size), + @intCast(font_group.atlas_color.size), + 0, + .BGRA, + .UnsignedByte, + font_group.atlas_color.data.ptr, + ); + } + + return .{ + .program = program, + .vao = vao, + .ebo = ebo, + .vbo = vbo, + .texture = tex, + .texture_color = tex_color, + }; + } + + pub fn deinit(self: *GLState) void { + self.texture.destroy(); + self.texture_color.destroy(); + self.vbo.destroy(); + self.ebo.destroy(); + self.vao.destroy(); + self.program.destroy(); + } +};