From b3f994a9d2e30fd9f5f3c0c12038d55551b13a6b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 12 Feb 2025 22:19:03 -0600 Subject: [PATCH 01/35] gtk: use builder ui files and popovers for menus --- src/apprt/gtk/App.zig | 112 ++------------- src/apprt/gtk/Builder.zig | 4 +- src/apprt/gtk/Surface.zig | 53 ++----- src/apprt/gtk/Tab.zig | 4 +- src/apprt/gtk/TabView.zig | 4 +- src/apprt/gtk/Window.zig | 101 ++++++++----- src/apprt/gtk/gresource.zig | 5 +- src/apprt/gtk/menu.zig | 135 ++++++++++++++++++ src/apprt/gtk/ui/menu-surface-context_menu.ui | 90 ++++++++++++ src/apprt/gtk/ui/menu-window-titlebar_menu.ui | 93 ++++++++++++ 10 files changed, 420 insertions(+), 181 deletions(-) create mode 100644 src/apprt/gtk/menu.zig create mode 100644 src/apprt/gtk/ui/menu-surface-context_menu.ui create mode 100644 src/apprt/gtk/ui/menu-window-titlebar_menu.ui diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 227c36ec4..2e820a957 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -57,12 +57,6 @@ single_instance: bool, /// The "none" cursor. We use one that is shared across the entire app. cursor_none: ?*c.GdkCursor, -/// The shared application menu. -menu: ?*c.GMenu = null, - -/// The shared context menu. -context_menu: ?*c.GMenu = null, - /// The configuration errors window, if it is currently open. config_errors_window: ?*ConfigErrorsWindow = null, @@ -426,8 +420,6 @@ pub fn terminate(self: *App) void { c.g_object_unref(self.app); if (self.cursor_none) |cursor| c.g_object_unref(cursor); - if (self.menu) |menu| c.g_object_unref(menu); - if (self.context_menu) |context_menu| c.g_object_unref(context_menu); if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); for (self.custom_css_providers.items) |provider| { @@ -456,7 +448,6 @@ pub fn performAction( }), .toggle_maximize => self.toggleMaximize(target), .toggle_fullscreen => self.toggleFullscreen(target, value), - .new_tab => try self.newTab(target), .close_tab => try self.closeTab(target), .goto_tab => return self.gotoTab(target, value), @@ -990,17 +981,19 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("app.quit", .{ .quit = {} }); try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); - try self.syncActionAccelerator("win.toggle_inspector", .{ .inspector = .toggle }); - try self.syncActionAccelerator("win.close", .{ .close_surface = {} }); - try self.syncActionAccelerator("win.new_window", .{ .new_window = {} }); - try self.syncActionAccelerator("win.new_tab", .{ .new_tab = {} }); - try self.syncActionAccelerator("win.split_right", .{ .new_split = .right }); - try self.syncActionAccelerator("win.split_down", .{ .new_split = .down }); - try self.syncActionAccelerator("win.split_left", .{ .new_split = .left }); - try self.syncActionAccelerator("win.split_up", .{ .new_split = .up }); + try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + try self.syncActionAccelerator("win.close", .{ .close_window = {} }); + try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); + try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); + try self.syncActionAccelerator("win.close-tab", .{ .close_tab = {} }); + try self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); + try self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); + try self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); + try self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); try self.syncActionAccelerator("win.reset", .{ .reset = {} }); + try self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); } fn syncActionAccelerator( @@ -1232,10 +1225,8 @@ pub fn run(self: *App) !void { // and asynchronously request the initial color scheme self.initDbus(); - // Setup our menu items + // Setup our actions self.initActions(); - self.initMenu(); - self.initContextMenu(); // On startup, we want to check for configuration errors right away // so we can show our error window. We also need to setup other initial @@ -1753,87 +1744,6 @@ fn initActions(self: *App) void { } } -/// Initializes and populates the provided GMenu with sections and actions. -/// This function is used to set up the application's menu structure, either for -/// the main menu button or as a context menu when window decorations are disabled. -fn initMenuContent(menu: *c.GMenu) void { - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "New Window", "win.new_window"); - c.g_menu_append(section, "New Tab", "win.new_tab"); - c.g_menu_append(section, "Close Tab", "win.close_tab"); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); - c.g_menu_append(section, "Close Window", "win.close"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); - c.g_menu_append(section, "Open Configuration", "app.open-config"); - c.g_menu_append(section, "Reload Configuration", "app.reload-config"); - c.g_menu_append(section, "About Ghostty", "win.about"); - } -} - -/// This sets the self.menu property to the application menu that can be -/// shared by all application windows. -fn initMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - initMenuContent(@ptrCast(menu)); - self.menu = menu; -} - -fn initContextMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Copy", "win.copy"); - c.g_menu_append(section, "Paste", "win.paste"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Reset", "win.reset"); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); - } - - const section = c.g_menu_new(); - defer c.g_object_unref(section); - const submenu = c.g_menu_new(); - defer c.g_object_unref(submenu); - - initMenuContent(@ptrCast(submenu)); - c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - - self.context_menu = menu; -} - -pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { - const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); - c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); -} - fn isValidAppId(app_id: [:0]const u8) bool { if (app_id.len > 255 or app_id.len == 0) return false; if (app_id[0] == '.') return false; diff --git a/src/apprt/gtk/Builder.zig b/src/apprt/gtk/Builder.zig index ffacd3adf..f9b0c226a 100644 --- a/src/apprt/gtk/Builder.zig +++ b/src/apprt/gtk/Builder.zig @@ -56,7 +56,7 @@ pub fn setWidgetClassTemplate(self: *const Builder, class: *gtk.WidgetClass) voi class.setTemplateFromResource(self.resource_name); } -pub fn getObject(self: *Builder, name: [:0]const u8) ?*gobject.Object { +pub fn getObject(self: *Builder, comptime T: type, name: [:0]const u8) ?*T { const builder = builder: { if (self.builder) |builder| break :builder builder; const builder = gtk.Builder.newFromResource(self.resource_name); @@ -64,7 +64,7 @@ pub fn getObject(self: *Builder, name: [:0]const u8) ?*gobject.Object { break :builder builder; }; - return builder.getObject(name); + return gobject.ext.cast(T, builder.getObject(name) orelse return null); } pub fn deinit(self: *const Builder) void { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9a8c4513d..9835b5b77 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -20,6 +20,7 @@ const App = @import("App.zig"); const Split = @import("Split.zig"); const Tab = @import("Tab.zig"); const Window = @import("Window.zig"); +const Menu = @import("menu.zig").Menu; const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); @@ -378,6 +379,9 @@ im_len: u7 = 0, /// details on what this is. cgroup_path: ?[]const u8 = null, +/// Our context menu. +context_menu: Menu(Surface, "context_menu", false), + /// The state of the key event while we're doing IM composition. /// See gtkKeyPressed for detailed descriptions. pub const IMKeyEvent = enum { @@ -576,9 +580,14 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .cursor_pos = .{ .x = -1, .y = -1 }, .im_context = im_context, .cgroup_path = cgroup_path, + .context_menu = undefined, }; errdefer self.* = undefined; + // initialize the context menu + self.context_menu.init(self); + self.context_menu.setParent(@ptrCast(@alignCast(overlay))); + // Set our default mouse shape try self.setMouseShape(.text); @@ -913,7 +922,7 @@ fn updateTitleLabels(self: *Surface) void { // If we have a tab and are the focused child, then we have to update the tab if (self.container.tab()) |tab| { - if (tab.focus_child == self) tab.setLabelText(title); + if (tab.focus_child == self) tab.setTitleText(title); } // If we have a window and are focused, then we have to update the window title. @@ -1224,6 +1233,7 @@ fn getClipboard(widget: *c.GtkWidget, clipboard: apprt.Clipboard) ?*c.GdkClipboa .selection, .primary => c.gtk_widget_get_primary_clipboard(widget), }; } + pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { return self.cursor_pos; } @@ -1261,40 +1271,6 @@ pub fn showDesktopNotification( c.g_application_send_notification(g_app, body.ptr, notification); } -fn showContextMenu(self: *Surface, x: f32, y: f32) void { - const window: *Window = self.container.window() orelse { - log.info( - "showContextMenu invalid for container={s}", - .{@tagName(self.container)}, - ); - return; - }; - - // Convert surface coordinate into coordinate space of the - // context menu's parent - var point: c.graphene_point_t = .{ .x = x, .y = y }; - if (c.gtk_widget_compute_point( - self.primaryWidget(), - c.gtk_widget_get_parent(@ptrCast(window.context_menu)), - &c.GRAPHENE_POINT_INIT(point.x, point.y), - @ptrCast(&point), - ) == 0) { - log.warn("failed computing point for context menu", .{}); - return; - } - - const rect: c.GdkRectangle = .{ - .x = @intFromFloat(point.x), - .y = @intFromFloat(point.y), - .width = 1, - .height = 1, - }; - - c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); - self.app.refreshContextMenu(window.window, self.core_surface.hasSelection()); - c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); -} - fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { log.debug("gl surface realized", .{}); @@ -1465,7 +1441,7 @@ fn gtkMouseDown( // word and returns false. We can use this to handle the context menu // opening under normal scenarios. if (!consumed and button == .right) { - self.showContextMenu(@floatCast(x), @floatCast(y)); + self.context_menu.popupAt(@intFromFloat(x), @intFromFloat(y)); } } @@ -2073,15 +2049,14 @@ fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo /// Adds the unfocused_widget to the overlay. If the unfocused_widget has already been added, this /// is a no-op pub fn dimSurface(self: *Surface) void { - const window = self.container.window() orelse { + _ = self.container.window() orelse { log.warn("dimSurface invalid for container={}", .{self.container}); return; }; // Don't dim surface if context menu is open. // This means we got unfocused due to it opening. - const context_menu_open = c.gtk_widget_get_visible(window.context_menu); - if (context_menu_open == 1) return; + if (self.context_menu.isVisible()) return; if (self.unfocused_widget != null) return; self.unfocused_widget = c.gtk_drawing_area_new(); diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index d320daa7c..214928790 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -108,8 +108,8 @@ pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void { self.elem = elem; } -pub fn setLabelText(self: *Tab, title: [:0]const u8) void { - self.window.notebook.setTabLabel(self, title); +pub fn setTitleText(self: *Tab, title: [:0]const u8) void { + self.window.notebook.setTabTitle(self, title); } pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void { diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 156fd0b93..efef3d621 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -165,7 +165,7 @@ pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { _ = self.tab_view.reorderPage(page, position); } -pub fn setTabLabel(self: *TabView, tab: *Tab, title: [:0]const u8) void { +pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void { const page = self.tab_view.getPage(@ptrCast(tab.box)); page.setTitle(title.ptr); } @@ -188,7 +188,7 @@ pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void { const position = self.newTabInsertPosition(tab); const box_widget: *gtk.Widget = @ptrCast(tab.box); const page = self.tab_view.insert(box_widget, position); - self.setTabLabel(tab, title); + self.setTabTitle(tab, title); self.tab_view.setSelectedPage(page); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3b415435a..cfd340d6c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -18,6 +18,7 @@ const CoreSurface = @import("../../Surface.zig"); const App = @import("App.zig"); const Color = configpkg.Config.Color; const Surface = @import("Surface.zig"); +const Menu = @import("menu.zig").Menu; const Tab = @import("Tab.zig"); const c = @import("c.zig").c; const adwaita = @import("adwaita.zig"); @@ -46,7 +47,8 @@ tab_overview: ?*c.GtkWidget, /// The notebook (tab grouping) for this window. notebook: TabView, -context_menu: *c.GtkWidget, +/// The "main" menu that is attached to a button in the headerbar. +titlebar_menu: Menu(Window, "titlebar_menu", true), /// The libadwaita widget for receiving toast send requests. toast_overlay: *c.GtkWidget, @@ -112,7 +114,7 @@ pub fn init(self: *Window, app: *App) !void { .headerbar = undefined, .tab_overview = null, .notebook = undefined, - .context_menu = undefined, + .titlebar_menu = undefined, .toast_overlay = undefined, .winproto = .none, }; @@ -137,6 +139,9 @@ pub fn init(self: *Window, app: *App) !void { // Create our box which will hold our widgets in the main content area. const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); + // Set up the menus + self.titlebar_menu.init(self); + // Setup our notebook self.notebook.init(self); @@ -174,7 +179,15 @@ pub fn init(self: *Window, app: *App) !void { const btn = c.gtk_menu_button_new(); c.gtk_widget_set_tooltip_text(btn, "Main Menu"); c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); - c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); + c.gtk_menu_button_set_popover(@ptrCast(btn), @ptrCast(@alignCast(self.titlebar_menu.asWidget()))); + _ = c.g_signal_connect_data( + btn, + "notify::active", + c.G_CALLBACK(>kTitlebarMenuActivate), + self, + null, + c.G_CONNECT_DEFAULT, + ); self.headerbar.packEnd(btn); } @@ -259,11 +272,6 @@ pub fn init(self: *Window, app: *App) !void { c.adw_tab_overview_set_view(@ptrCast(tab_overview), @ptrCast(@alignCast(self.notebook.tab_view))); } - self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); - c.gtk_widget_set_parent(self.context_menu, box); - c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0); - c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START); - // We register a key event controller with the window so // we can catch key events when our surface may not be // focused (i.e. when the libadw tab overview is shown). @@ -272,7 +280,6 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_add_controller(gtk_widget, ec_key_press); // All of our events - _ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(>kRefocusTerm), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(self.window, "realize", c.G_CALLBACK(>kRealize), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(self.window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(self.window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); @@ -459,16 +466,18 @@ fn initActions(self: *Window) void { const actions = .{ .{ "about", >kActionAbout }, .{ "close", >kActionClose }, - .{ "new_window", >kActionNewWindow }, - .{ "new_tab", >kActionNewTab }, - .{ "split_right", >kActionSplitRight }, - .{ "split_down", >kActionSplitDown }, - .{ "split_left", >kActionSplitLeft }, - .{ "split_up", >kActionSplitUp }, - .{ "toggle_inspector", >kActionToggleInspector }, + .{ "new-window", >kActionNewWindow }, + .{ "new-tab", >kActionNewTab }, + .{ "close-tab", >kActionCloseTab }, + .{ "split-right", >kActionSplitRight }, + .{ "split-down", >kActionSplitDown }, + .{ "split-left", >kActionSplitLeft }, + .{ "split-up", >kActionSplitUp }, + .{ "toggle-inspector", >kActionToggleInspector }, .{ "copy", >kActionCopy }, .{ "paste", >kActionPaste }, .{ "reset", >kActionReset }, + .{ "clear", >kActionClear }, }; inline for (actions) |entry| { @@ -487,8 +496,6 @@ fn initActions(self: *Window) void { } pub fn deinit(self: *Window) void { - c.gtk_widget_unparent(@ptrCast(self.context_menu)); - self.winproto.deinit(self.app.core_app.alloc); if (self.adw_tab_overview_focus_timer) |timer| { @@ -752,16 +759,6 @@ fn adwTabOverviewFocusTimer( return 0; } -fn gtkRefocusTerm(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { - _ = v; - log.debug("refocus term request", .{}); - const self = userdataSelf(ud.?); - - self.focusCurrentTab(); - - return true; -} - fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { _ = v; log.debug("window close request", .{}); @@ -919,11 +916,7 @@ fn gtkActionClose( ud: ?*anyopaque, ) callconv(.C) void { const self: *Window = @ptrCast(@alignCast(ud orelse return)); - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .close_surface = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + c.gtk_window_destroy(self.window); } fn gtkActionNewWindow( @@ -948,6 +941,19 @@ fn gtkActionNewTab( gtkTabNewClick(undefined, ud); } +fn gtkActionCloseTab( + _: *c.GSimpleAction, + _: *c.GVariant, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud orelse return)); + const surface = self.actionSurface() orelse return; + _ = surface.performBindingAction(.{ .close_tab = {} }) catch |err| { + log.warn("error performing binding action error={}", .{err}); + return; + }; +} + fn gtkActionSplitRight( _: *c.GSimpleAction, _: *c.GVariant, @@ -1052,13 +1058,40 @@ fn gtkActionReset( }; } +fn gtkActionClear( + _: *c.GSimpleAction, + _: *c.GVariant, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud orelse return)); + const surface = self.actionSurface() orelse return; + _ = surface.performBindingAction(.{ .clear_screen = {} }) catch |err| { + log.warn("error performing binding action error={}", .{err}); + return; + }; +} + /// Returns the surface to use for an action. -fn actionSurface(self: *Window) ?*CoreSurface { +pub fn actionSurface(self: *Window) ?*CoreSurface { const tab = self.notebook.currentTab() orelse return null; const surface = tab.focus_child orelse return null; return &surface.core_surface; } +fn gtkTitlebarMenuActivate( + btn: *c.GtkMenuButton, + _: *c.GParamSpec, + ud: ?*anyopaque, +) callconv(.C) void { + const active = c.gtk_menu_button_get_active(btn) != 0; + const self = userdataSelf(ud orelse return); + if (active) { + self.titlebar_menu.refresh(); + } else { + self.focusCurrentTab(); + } +} + fn userdataSelf(ud: *anyopaque) *Window { return @ptrCast(@alignCast(ud)); } diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index 050605b00..d45997d6c 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -53,7 +53,10 @@ const icons = [_]struct { }, }; -pub const ui_files = [_][]const u8{}; +pub const ui_files = [_][]const u8{ + "menu-window-titlebar_menu", + "menu-surface-context_menu", +}; pub const blueprint_files = [_][]const u8{}; pub fn main() !void { diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig new file mode 100644 index 000000000..c36ace646 --- /dev/null +++ b/src/apprt/gtk/menu.zig @@ -0,0 +1,135 @@ +const std = @import("std"); + +const gtk = @import("gtk"); +const gdk = @import("gdk"); +const gio = @import("gio"); +const gobject = @import("gobject"); + +const apprt = @import("../../apprt.zig"); +const App = @import("App.zig"); +const Window = @import("Window.zig"); +const Surface = @import("Surface.zig"); +const Builder = @import("Builder.zig"); + +/// Abstract GTK menus to take advantage of machinery for buildtime/comptime +/// error checking. +pub fn Menu( + /// GTK apprt type that the menu is "for". Window and Surface are supported + /// right now. + comptime T: type, + /// Name of the menu. Along with the apprt type, this is used to look up the + /// builder ui definitions of the menu. + comptime menu_name: []const u8, + /// Should the popup have a pointer pointing to the location that it's + /// attached to. + comptime arrow: bool, +) type { + return struct { + const Self = @This(); + + /// parent apprt object + parent: *T, + + /// our widget + menu_widget: *gtk.PopoverMenu, + + /// initialize the menu + pub fn init(self: *Self, parent: *T) void { + const object_type = switch (T) { + Window => "window", + Surface => "surface", + else => unreachable, + }; + + var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, .ui); + defer builder.deinit(); + + const menu_model = builder.getObject(gio.MenuModel, "menu").?; + + const menu_widget = gtk.PopoverMenu.newFromModelFull(menu_model, .{ .nested = true }); + menu_widget.as(gtk.Popover).setHasArrow(@intFromBool(arrow)); + _ = gtk.Popover.signals.closed.connect( + menu_widget, + *Self, + gtkRefocusTerm, + self, + .{}, + ); + + self.* = .{ + .parent = parent, + .menu_widget = menu_widget, + }; + } + + pub fn setParent(self: *const Self, widget: *gtk.Widget) void { + self.menu_widget.as(gtk.Widget).setParent(widget); + } + + pub fn asWidget(self: *const Self) *gtk.Widget { + return self.menu_widget.as(gtk.Widget); + } + + pub fn isVisible(self: *const Self) bool { + return self.menu_widget.as(gtk.Widget).getVisible() != 0; + } + + pub fn setVisible(self: *const Self, visible: bool) void { + self.menu_widget.as(gtk.Widget).setVisible(@intFromBool(visible)); + } + + /// Refresh the menu. Right now that means enabling/disabling the "Copy" + /// menu item based on whether there is an active selection or not, but + /// that may change in the future. + pub fn refresh(self: *const Self) void { + const window: *gtk.Window, const has_selection: bool = switch (T) { + Window => window: { + const core_surface = self.parent.actionSurface() orelse break :window .{ + @ptrCast(@alignCast(self.parent.window)), + false, + }; + const has_selection = core_surface.hasSelection(); + break :window .{ @ptrCast(@alignCast(self.parent.window)), has_selection }; + }, + Surface => surface: { + const window = self.parent.container.window() orelse return; + const has_selection = self.parent.core_surface.hasSelection(); + break :surface .{ @ptrCast(@alignCast(window.window)), has_selection }; + }, + else => unreachable, + }; + + const action_map: *gio.ActionMap = gobject.ext.cast(gio.ActionMap, window) orelse return; + const action: *gio.SimpleAction = gobject.ext.cast( + gio.SimpleAction, + action_map.lookupAction("copy") orelse return, + ) orelse return; + action.setEnabled(@intFromBool(has_selection)); + } + + /// Pop up the menu at the given coordinates + pub fn popupAt(self: *const Self, x: c_int, y: c_int) void { + const rect: gdk.Rectangle = .{ + .f_x = x, + .f_y = y, + .f_width = 1, + .f_height = 1, + }; + const popover = self.menu_widget.as(gtk.Popover); + popover.setPointingTo(&rect); + self.refresh(); + popover.popup(); + } + + /// Refocus tab that lost focus because of the popover menu + fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.C) void { + const window: *Window = switch (T) { + Window => self.parent, + Surface => self.parent.container.window() orelse return, + else => unreachable, + }; + + window.focusCurrentTab(); + } + }; +} diff --git a/src/apprt/gtk/ui/menu-surface-context_menu.ui b/src/apprt/gtk/ui/menu-surface-context_menu.ui new file mode 100644 index 000000000..b5bf55cef --- /dev/null +++ b/src/apprt/gtk/ui/menu-surface-context_menu.ui @@ -0,0 +1,90 @@ + + + + +
+ + Copy + win.copy + + + Paste + win.paste + +
+
+ + Clear + win.clear + + + Reset + win.reset + +
+
+ + Split +
+ + Split Up + win.split-up + + + Split Down + win.split-down + + + Split Left + win.split-left + + + Split Right + win.split-right + +
+
+ + Tab +
+ + New Tab + win.new-tab + + + Close Tab + win.close-tab + +
+
+ + Window +
+ + New Window + win.new-window + + + Close Window + win.close + +
+
+
+
+ + Config +
+ + Open Configuration + app.open-config + + + Reload Configuration + app.reload-config + +
+
+
+
+
diff --git a/src/apprt/gtk/ui/menu-window-titlebar_menu.ui b/src/apprt/gtk/ui/menu-window-titlebar_menu.ui new file mode 100644 index 000000000..6359b993c --- /dev/null +++ b/src/apprt/gtk/ui/menu-window-titlebar_menu.ui @@ -0,0 +1,93 @@ + + + + +
+ + Copy + win.copy + + + Paste + win.paste + +
+
+ + New Window + win.new-window + + + Close Window + win.close + +
+
+ + New Tab + win.new-tab + + + Close Tab + win.close-tab + +
+
+ + Split +
+ + Split Up + win.split-up + + + Split Down + win.split-down + + + Split Left + win.split-left + + + Split Right + win.split-right + +
+
+
+
+ + Clear + win.clear + + + Reset + win.reset + +
+
+ + Terminal Inspector + win.toggle-inspector + + + Open Configuration + app.open-config + + + Reload Configuration + app.reload-config + +
+
+ + About Ghostty + win.about + + + Quit + app.quit + +
+
+
From 2d5a07c79574b48a186cb2a3a10824c7944df895 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 16 Feb 2025 14:48:50 -0600 Subject: [PATCH 02/35] gtk: fix build on debian 12 --- src/apprt/gtk/Window.zig | 2 ++ src/build/docker/debian/Dockerfile | 1 + 2 files changed, 3 insertions(+) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index cfd340d6c..7b74da722 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -1083,6 +1083,8 @@ fn gtkTitlebarMenuActivate( _: *c.GParamSpec, ud: ?*anyopaque, ) callconv(.C) void { + // debian 12 is stuck on GTK 4.8 + if (!version.atLeast(4, 10, 0)) return; const active = c.gtk_menu_button_get_active(btn) != 0; const self = userdataSelf(ud orelse return); if (active) { diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index 61e9e75c1..307fb7521 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -10,6 +10,7 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ libonig-dev \ lintian \ lsb-release \ + libxml2-utils \ pandoc \ wget \ # Ghostty Dependencies From d1fa93300642cdf8f8ed5b4f759d3960982f267a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 18 Feb 2025 23:18:47 -0600 Subject: [PATCH 03/35] gtk: forward config updates to GTK apprt surfaces --- src/apprt/gtk/App.zig | 3 +++ src/apprt/gtk/Surface.zig | 6 ++++++ src/apprt/gtk/Window.zig | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 227c36ec4..ea3be6d5f 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -892,6 +892,9 @@ fn configChange( ) void { switch (target) { .surface => |surface| surface: { + surface.rt_surface.updateConfig(new_config) catch |err| { + log.err("unable to update surface config: {}", .{err}); + }; const window = surface.rt_surface.container.window() orelse break :surface; window.updateConfig(new_config) catch |err| { log.warn("error updating config for window err={}", .{err}); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9a8c4513d..5cbba1ba1 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -682,6 +682,12 @@ pub fn deinit(self: *Surface) void { self.resize_overlay.deinit(); } +/// Update our local copy of any configuration that we use. +pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void { + _ = self; + _ = config; +} + // unref removes the long-held reference to the gl_area and kicks off the // deinit/destroy process for this surface. pub fn unref(self: *Surface) void { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3b415435a..a0b9516c6 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -31,6 +31,10 @@ const log = std.log.scoped(.gtk); app: *App, +/// Used to deduplicate updateConfig invocations +last_config: usize, + +/// Local copy of any configuration config: DerivedConfig, /// Our window @@ -107,6 +111,7 @@ pub fn init(self: *Window, app: *App) !void { // Set up our own state self.* = .{ .app = app, + .last_config = @intFromPtr(&app.config), .config = DerivedConfig.init(&app.config), .window = undefined, .headerbar = undefined, @@ -355,6 +360,13 @@ pub fn updateConfig( self: *Window, config: *const configpkg.Config, ) !void { + // avoid multiple reconfigs when we have many surfaces contained in this + // surface using the integer value of config as a simple marker to know if + // we've "seen" this particular config before + const this_config = @intFromPtr(config); + if (self.last_config == this_config) return; + self.last_config = this_config; + self.config = DerivedConfig.init(config); // We always resync our appearance whenever the config changes. From 2697061e5b2d80fa5e8b65cc1966299886cb4b35 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 18 Feb 2025 23:28:10 -0600 Subject: [PATCH 04/35] gtk: fix comment in Window.updateConfig --- src/apprt/gtk/Window.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index a0b9516c6..8a6ebc71c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -361,7 +361,7 @@ pub fn updateConfig( config: *const configpkg.Config, ) !void { // avoid multiple reconfigs when we have many surfaces contained in this - // surface using the integer value of config as a simple marker to know if + // window using the integer value of config as a simple marker to know if // we've "seen" this particular config before const this_config = @intFromPtr(config); if (self.last_config == this_config) return; From 7c6375f744cba0f617a8f1f71e190881522df36b Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:53:27 +0800 Subject: [PATCH 05/35] Allow whitespace after commas in ColorList values --- src/config/Config.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a5f5b56b3..5426c1077 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4338,7 +4338,9 @@ pub const ColorList = struct { count += 1; if (count > 64) return error.InvalidValue; - const color = try Color.parseCLI(raw); + // Trim whitespace from each color value + const trimmed = std.mem.trim(u8, raw, " \t"); + const color = try Color.parseCLI(trimmed); try self.colors.append(alloc, color); try self.colors_c.append(alloc, color.cval()); } From 1b6b029e0d10ad69d015cd0d60e5995d99e21d56 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sat, 22 Feb 2025 22:11:35 +0800 Subject: [PATCH 06/35] Add test cases for whitespace handling for ColorList --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 5426c1077..c87c02867 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4409,6 +4409,14 @@ pub const ColorList = struct { try p.parseCLI(alloc, "black,white"); try testing.expectEqual(2, p.colors.items.len); + // Test whitespace handling + try p.parseCLI(alloc, "black, white"); // space after comma + try testing.expectEqual(2, p.colors.items.len); + try p.parseCLI(alloc, "black , white"); // spaces around comma + try testing.expectEqual(2, p.colors.items.len); + try p.parseCLI(alloc, " black , white "); // extra spaces at ends + try testing.expectEqual(2, p.colors.items.len); + // Error cases try testing.expectError(error.ValueRequired, p.parseCLI(alloc, null)); try testing.expectError(error.InvalidValue, p.parseCLI(alloc, " ")); From 6b75ca40ca2a1e092c92eb0a9575a231abee140b Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Wed, 19 Feb 2025 20:55:15 +0100 Subject: [PATCH 07/35] Implement a prompt that allows the user to set the title --- src/apprt/gtk/App.zig | 103 +++++++++++++++++++++++++++++++++++++- src/apprt/gtk/Surface.zig | 35 +++++++++++++ src/apprt/gtk/Window.zig | 14 ++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b26bc046f..9323d88cb 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -495,6 +495,7 @@ pub fn performAction( .toggle_split_zoom => self.toggleSplitZoom(target), .toggle_window_decorations => self.toggleWindowDecorations(target), .quit_timer => self.quitTimer(value), + .prompt_title => try self.promptTitle(target), // Unimplemented .close_all_windows, @@ -506,7 +507,6 @@ pub fn performAction( .render_inspector, .renderer_health, .color_change, - .prompt_title, => { log.warn("unimplemented action={}", .{action}); return false; @@ -770,6 +770,15 @@ fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { } } +fn promptTitle(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |v| { + try v.rt_surface.promptTitle(); + }, + } +} + fn setTitle( _: *App, target: apprt.Target, @@ -1016,6 +1025,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); try self.syncActionAccelerator("win.reset", .{ .reset = {} }); try self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); + try self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); } fn syncActionAccelerator( @@ -1766,6 +1776,97 @@ fn initActions(self: *App) void { } } +<<<<<<< HEAD +======= +/// Initializes and populates the provided GMenu with sections and actions. +/// This function is used to set up the application's menu structure, either for +/// the main menu button or as a context menu when window decorations are disabled. +fn initMenuContent(menu: *c.GMenu) void { + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "New Window", "win.new_window"); + c.g_menu_append(section, "New Tab", "win.new_tab"); + c.g_menu_append(section, "Close Tab", "win.close_tab"); + c.g_menu_append(section, "Split Right", "win.split_right"); + c.g_menu_append(section, "Split Down", "win.split_down"); + c.g_menu_append(section, "Close Window", "win.close"); + } + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); + c.g_menu_append(section, "Open Configuration", "app.open-config"); + c.g_menu_append(section, "Reload Configuration", "app.reload-config"); + c.g_menu_append(section, "About Ghostty", "win.about"); + } +} + +/// This sets the self.menu property to the application menu that can be +/// shared by all application windows. +fn initMenu(self: *App) void { + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + initMenuContent(@ptrCast(menu)); + self.menu = menu; +} + +fn initContextMenu(self: *App) void { + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Copy", "win.copy"); + c.g_menu_append(section, "Paste", "win.paste"); + } + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Split Right", "win.split_right"); + c.g_menu_append(section, "Split Down", "win.split_down"); + } + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Change Title...", "win.prompt-title"); + } + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Reset", "win.reset"); + c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); + } + + const section = c.g_menu_new(); + defer c.g_object_unref(section); + const submenu = c.g_menu_new(); + defer c.g_object_unref(submenu); + + initMenuContent(@ptrCast(submenu)); + c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + + self.context_menu = menu; +} + +pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { + const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); + c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); +} + +>>>>>>> a9ae01e38 (Implement a prompt that allows the user to set the title) fn isValidAppId(app_id: [:0]const u8) bool { if (app_id.len > 255 or app_id.len == 0) return false; if (app_id[0] == '.') return false; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9835b5b77..beff7b9ce 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -987,6 +987,31 @@ pub fn getTitle(self: *Surface) ?[:0]const u8 { return null; } +const PromptTitleDialogContext = struct { + entry: *c.GtkWidget, + self: *Surface, +}; + +pub fn promptTitle(self: *Surface) !void { + const window = self.container.window() orelse return; + + const context = try self.app.core_app.alloc.create(PromptTitleDialogContext); + context.self = self; + + const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_OK_CANCEL, "Set Tab Title"); + + const content_area = c.gtk_message_dialog_get_message_area(@ptrCast(dialog)); + + const entry = c.gtk_entry_new(); + context.entry = entry; + c.gtk_box_append(@ptrCast(content_area), entry); + c.gtk_widget_show(entry); + + _ = c.g_signal_connect_data(dialog, "response", c.G_CALLBACK(>kPromptTitleResponse), context, null, c.G_CONNECT_DEFAULT); + + c.gtk_widget_show(dialog); +} + /// Set the current working directory of the surface. /// /// In addition, update the tab's tooltip text, and if we are the focused child, @@ -2273,3 +2298,13 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { } return false; } + +fn gtkPromptTitleResponse(dialog: *c.GtkDialog, response: c.gint, ud: ?*anyopaque) callconv(.C) void { + const context: *PromptTitleDialogContext = @ptrCast(@alignCast(ud)); + if (response == c.GTK_RESPONSE_OK) { + const buffer = c.gtk_entry_get_buffer(@ptrCast(context.entry)); + const title = c.gtk_entry_buffer_get_text(buffer); + context.self.setTitle(std.mem.span(title)) catch {}; + } + c.gtk_window_destroy(@ptrCast(dialog)); +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 7b74da722..22148706c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -478,6 +478,7 @@ fn initActions(self: *Window) void { .{ "paste", >kActionPaste }, .{ "reset", >kActionReset }, .{ "clear", >kActionClear }, + .{ "prompt_title", >kActionPromptTitle }, }; inline for (actions) |entry| { @@ -1071,6 +1072,19 @@ fn gtkActionClear( }; } +fn gtkActionPromptTitle( + _: *c.GSimpleAction, + _: *c.GVariant, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud orelse return)); + const surface = self.actionSurface() orelse return; + _ = surface.performBindingAction(.{ .prompt_surface_title = {} }) catch |err| { + log.warn("error performing binding action error={}", .{err}); + return; + }; +} + /// Returns the surface to use for an action. pub fn actionSurface(self: *Window) ?*CoreSurface { const tab = self.notebook.currentTab() orelse return null; From 5e9908af279a7d1cae607a78b61fff947850efb2 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Thu, 20 Feb 2025 20:39:59 +0100 Subject: [PATCH 08/35] make the change of the title persistent & allow the user to restore to a default one --- src/apprt/gtk/App.zig | 2 +- src/apprt/gtk/Surface.zig | 71 ++++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 9323d88cb..fec2626bd 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -786,7 +786,7 @@ fn setTitle( ) !void { switch (target) { .app => {}, - .surface => |v| try v.rt_surface.setTitle(title.title), + .surface => |v| try v.rt_surface.setTitle(title.title, .TERMINAL), } } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index beff7b9ce..7fdac9aa8 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -347,6 +347,11 @@ cursor: ?*c.GdkCursor = null, /// pass it to GTK. title_text: ?[:0]const u8 = null, +/// The title of the surface as reported by the terminal. +/// If it is null, the title reported by the terminal is currently being used. +/// If the title was manually overriden by the user, this will be set to a non-null value representing the default terminal title. +title_from_terminal: ?[:0]const u8 = null, + /// Our current working directory. We use this value for setting tooltips in /// the headerbar subtitle if we have focus. When set, the text in this buf /// will be null-terminated because we need to pass it to GTK. @@ -940,8 +945,9 @@ fn updateTitleLabels(self: *Surface) void { } const zoom_title_prefix = "🔍 "; +pub const SetTitleSource = enum { USER, TERMINAL }; -pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { +pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void { const alloc = self.app.core_app.alloc; // Always allocate with the "🔍 " at the beginning and slice accordingly @@ -954,6 +960,14 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { }; errdefer alloc.free(copy); + // If The user has overriden the title we only want to update the terminal provided title + // so that it can be restored to the most recent state + if (self.title_from_terminal != null and source == .TERMINAL) { + alloc.free(self.title_from_terminal.?); + self.title_from_terminal = copy; + return; + } + if (self.title_text) |old| alloc.free(old); self.title_text = copy; @@ -978,15 +992,27 @@ fn updateTitleTimerExpired(ctx: ?*anyopaque) callconv(.C) c.gboolean { pub fn getTitle(self: *Surface) ?[:0]const u8 { if (self.title_text) |title_text| { - return if (self.zoomed_in) - title_text - else - title_text[zoom_title_prefix.len..]; + return self.resolveTitle(title_text); } return null; } +pub fn getTerminalTitle(self: *Surface) ?[:0]const u8 { + if (self.title_from_terminal) |title_text| { + return self.resolveTitle(title_text); + } + + return null; +} + +fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 { + return if (self.zoomed_in) + title + else + title[zoom_title_prefix.len..]; +} + const PromptTitleDialogContext = struct { entry: *c.GtkWidget, self: *Surface, @@ -998,12 +1024,15 @@ pub fn promptTitle(self: *Surface) !void { const context = try self.app.core_app.alloc.create(PromptTitleDialogContext); context.self = self; - const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_OK_CANCEL, "Set Tab Title"); + const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_OK_CANCEL, "Change Terminal Title"); + c.gtk_message_dialog_format_secondary_text(@ptrCast(dialog), "Leave blank to restore the default title."); const content_area = c.gtk_message_dialog_get_message_area(@ptrCast(dialog)); const entry = c.gtk_entry_new(); context.entry = entry; + const buffer = c.gtk_entry_get_buffer(@ptrCast(entry)); + c.gtk_entry_buffer_set_text(buffer, self.getTitle() orelse "", -1); c.gtk_box_append(@ptrCast(content_area), entry); c.gtk_widget_show(entry); @@ -2301,10 +2330,36 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { fn gtkPromptTitleResponse(dialog: *c.GtkDialog, response: c.gint, ud: ?*anyopaque) callconv(.C) void { const context: *PromptTitleDialogContext = @ptrCast(@alignCast(ud)); + if (response == c.GTK_RESPONSE_OK) { const buffer = c.gtk_entry_get_buffer(@ptrCast(context.entry)); - const title = c.gtk_entry_buffer_get_text(buffer); - context.self.setTitle(std.mem.span(title)) catch {}; + const title = std.mem.span(c.gtk_entry_buffer_get_text(buffer)); + + // if the new title is empty and the user has set the title previously, restore the terminal provided title + if (title.len == 0 and context.self.title_from_terminal != null) { + if (context.self.getTerminalTitle()) |terminal_title| { + context.self.setTitle(terminal_title, .USER) catch {}; + context.self.app.core_app.alloc.free(context.self.title_from_terminal.?); + context.self.title_from_terminal = null; + } + } else { + // if this is the first time the user is setting the title, save the current terminal provided title + if (context.self.title_from_terminal == null and context.self.title_text != null) { + const current_title = context.self.getTitle().?; + context.self.title_from_terminal = context.self.app.core_app.alloc.dupeZ(u8, current_title) catch |err| switch (err) { + error.OutOfMemory => { + log.err("Failed to allocate memory for title: {}", .{err}); + context.self.app.core_app.alloc.destroy(context); + c.gtk_window_destroy(@ptrCast(dialog)); + return; + }, + }; + } + + context.self.setTitle(title, .USER) catch {}; + } } + + context.self.app.core_app.alloc.destroy(context); c.gtk_window_destroy(@ptrCast(dialog)); } From cc9c45de2a5795a0f0085694eb75e536f7a44d87 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Thu, 20 Feb 2025 20:55:50 +0100 Subject: [PATCH 09/35] fix the edge case when user tries to revert the title to default and hasn't set a title before --- src/apprt/gtk/Surface.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7fdac9aa8..af20bceaa 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -960,8 +960,8 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !vo }; errdefer alloc.free(copy); - // If The user has overriden the title we only want to update the terminal provided title - // so that it can be restored to the most recent state + // The user has overriden the title + // We only want to update the terminal provided title so that it can be restored to the most recent state. if (self.title_from_terminal != null and source == .TERMINAL) { alloc.free(self.title_from_terminal.?); self.title_from_terminal = copy; @@ -2342,11 +2342,10 @@ fn gtkPromptTitleResponse(dialog: *c.GtkDialog, response: c.gint, ud: ?*anyopaqu context.self.app.core_app.alloc.free(context.self.title_from_terminal.?); context.self.title_from_terminal = null; } - } else { + } else if (title.len > 0) { // if this is the first time the user is setting the title, save the current terminal provided title if (context.self.title_from_terminal == null and context.self.title_text != null) { - const current_title = context.self.getTitle().?; - context.self.title_from_terminal = context.self.app.core_app.alloc.dupeZ(u8, current_title) catch |err| switch (err) { + context.self.title_from_terminal = context.self.app.core_app.alloc.dupeZ(u8, context.self.title_text.?) catch |err| switch (err) { error.OutOfMemory => { log.err("Failed to allocate memory for title: {}", .{err}); context.self.app.core_app.alloc.destroy(context); From 95fc5ad1e9f5022c83346706fab7010f7bde1797 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:02:07 +0100 Subject: [PATCH 10/35] remove outdated comment --- src/input/Binding.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f91967293..d1e66210b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -349,7 +349,6 @@ pub const Action = union(enum) { toggle_tab_overview: void, /// Change the title of the current focused surface via a prompt. - /// This only works on macOS currently. prompt_surface_title: void, /// Create a new split in the given direction. From 3542778d848019f815c4b7a83b9bc4b3a61eff2c Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:20:03 +0100 Subject: [PATCH 11/35] free the terminal title when destroy is run --- src/apprt/gtk/Surface.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index af20bceaa..72482b59e 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -668,6 +668,7 @@ fn realize(self: *Surface) !void { pub fn deinit(self: *Surface) void { self.init_config.deinit(self.app.core_app.alloc); if (self.title_text) |title| self.app.core_app.alloc.free(title); + if (self.title_from_terminal) |title| self.app.core_app.alloc.free(title); if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd); // We don't allocate anything if we aren't realized. From dcd17c6ac4953a0086272bb1339aba15a3913b3f Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:31:41 +0100 Subject: [PATCH 12/35] set the ok widget to be activated by default --- src/apprt/gtk/Surface.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 72482b59e..42662f2da 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1025,8 +1025,10 @@ pub fn promptTitle(self: *Surface) !void { const context = try self.app.core_app.alloc.create(PromptTitleDialogContext); context.self = self; - const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_QUESTION, c.GTK_BUTTONS_OK_CANCEL, "Change Terminal Title"); + const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_OTHER, c.GTK_BUTTONS_OK_CANCEL, "Change Terminal Title"); c.gtk_message_dialog_format_secondary_text(@ptrCast(dialog), "Leave blank to restore the default title."); + const ok_widget = c.gtk_dialog_get_widget_for_response(@ptrCast(dialog), c.GTK_RESPONSE_OK); + c.gtk_window_set_default_widget(@ptrCast(dialog), ok_widget); const content_area = c.gtk_message_dialog_get_message_area(@ptrCast(dialog)); @@ -1035,6 +1037,7 @@ pub fn promptTitle(self: *Surface) !void { const buffer = c.gtk_entry_get_buffer(@ptrCast(entry)); c.gtk_entry_buffer_set_text(buffer, self.getTitle() orelse "", -1); c.gtk_box_append(@ptrCast(content_area), entry); + c.gtk_entry_set_activates_default(@ptrCast(entry), 1); c.gtk_widget_show(entry); _ = c.g_signal_connect_data(dialog, "response", c.G_CALLBACK(>kPromptTitleResponse), context, null, c.G_CONNECT_DEFAULT); From 6189f5d09ec29a5010be360e8ca4e18f72665ac1 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Fri, 21 Feb 2025 21:53:28 +0100 Subject: [PATCH 13/35] code review: - use blueprint for the dialog content - use zig-gobject bindings - make the enum values lowercase --- src/apprt/gtk/App.zig | 2 +- src/apprt/gtk/Surface.zig | 63 ++++++++++-------------- src/apprt/gtk/gresource.zig | 2 +- src/apprt/gtk/ui/prompt-title-dialog.blp | 9 ++++ 4 files changed, 38 insertions(+), 38 deletions(-) create mode 100644 src/apprt/gtk/ui/prompt-title-dialog.blp diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index fec2626bd..481e35e20 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -786,7 +786,7 @@ fn setTitle( ) !void { switch (target) { .app => {}, - .surface => |v| try v.rt_surface.setTitle(title.title, .TERMINAL), + .surface => |v| try v.rt_surface.setTitle(title.title, .terminal), } } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 42662f2da..589e32ef1 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -4,6 +4,10 @@ const Surface = @This(); const std = @import("std"); +const adw = @import("adw"); +const gtk = @import("gtk"); +const gio = @import("gio"); +const glib = @import("glib"); const Allocator = std.mem.Allocator; const build_config = @import("../../build_config.zig"); const build_options = @import("build_options"); @@ -26,6 +30,7 @@ const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; +const Builder = @import("Builder.zig"); const log = std.log.scoped(.gtk_surface); @@ -946,7 +951,7 @@ fn updateTitleLabels(self: *Surface) void { } const zoom_title_prefix = "🔍 "; -pub const SetTitleSource = enum { USER, TERMINAL }; +pub const SetTitleSource = enum { user, terminal }; pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void { const alloc = self.app.core_app.alloc; @@ -963,7 +968,7 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !vo // The user has overriden the title // We only want to update the terminal provided title so that it can be restored to the most recent state. - if (self.title_from_terminal != null and source == .TERMINAL) { + if (self.title_from_terminal != null and source == .terminal) { alloc.free(self.title_from_terminal.?); self.title_from_terminal = copy; return; @@ -1022,27 +1027,14 @@ const PromptTitleDialogContext = struct { pub fn promptTitle(self: *Surface) !void { const window = self.container.window() orelse return; - const context = try self.app.core_app.alloc.create(PromptTitleDialogContext); - context.self = self; + var builder = Builder.init("prompt-title-dialog", .blp); + defer builder.deinit(); - const dialog = c.gtk_message_dialog_new(window.window, c.GTK_DIALOG_MODAL, c.GTK_MESSAGE_OTHER, c.GTK_BUTTONS_OK_CANCEL, "Change Terminal Title"); - c.gtk_message_dialog_format_secondary_text(@ptrCast(dialog), "Leave blank to restore the default title."); - const ok_widget = c.gtk_dialog_get_widget_for_response(@ptrCast(dialog), c.GTK_RESPONSE_OK); - c.gtk_window_set_default_widget(@ptrCast(dialog), ok_widget); + const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); + dialog.addResponse("cancel", "Cancel"); + dialog.addResponse("ok", "OK"); - const content_area = c.gtk_message_dialog_get_message_area(@ptrCast(dialog)); - - const entry = c.gtk_entry_new(); - context.entry = entry; - const buffer = c.gtk_entry_get_buffer(@ptrCast(entry)); - c.gtk_entry_buffer_set_text(buffer, self.getTitle() orelse "", -1); - c.gtk_box_append(@ptrCast(content_area), entry); - c.gtk_entry_set_activates_default(@ptrCast(entry), 1); - c.gtk_widget_show(entry); - - _ = c.g_signal_connect_data(dialog, "response", c.G_CALLBACK(>kPromptTitleResponse), context, null, c.G_CONNECT_DEFAULT); - - c.gtk_widget_show(dialog); + dialog.choose(@ptrCast(window.window), null, @ptrCast(>kPromptTitleResponse), self); } /// Set the current working directory of the surface. @@ -2332,37 +2324,36 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { return false; } -fn gtkPromptTitleResponse(dialog: *c.GtkDialog, response: c.gint, ud: ?*anyopaque) callconv(.C) void { - const context: *PromptTitleDialogContext = @ptrCast(@alignCast(ud)); +fn gtkPromptTitleResponse(dialog: *adw.AlertDialog, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + const self = userdataSelf(ud.?); - if (response == c.GTK_RESPONSE_OK) { - const buffer = c.gtk_entry_get_buffer(@ptrCast(context.entry)); - const title = std.mem.span(c.gtk_entry_buffer_get_text(buffer)); + const response = dialog.chooseFinish(result); + if (glib.strEqual("ok", response) != 0) { + const title_entry: *gtk.Entry = @ptrCast(dialog.getExtraChild()); + const title = std.mem.span(title_entry.getBuffer().getText()); // if the new title is empty and the user has set the title previously, restore the terminal provided title - if (title.len == 0 and context.self.title_from_terminal != null) { - if (context.self.getTerminalTitle()) |terminal_title| { - context.self.setTitle(terminal_title, .USER) catch {}; - context.self.app.core_app.alloc.free(context.self.title_from_terminal.?); - context.self.title_from_terminal = null; + if (title.len == 0 and self.title_from_terminal != null) { + if (self.getTerminalTitle()) |terminal_title| { + self.setTitle(terminal_title, .user) catch {}; + self.app.core_app.alloc.free(self.title_from_terminal.?); + self.title_from_terminal = null; } } else if (title.len > 0) { // if this is the first time the user is setting the title, save the current terminal provided title - if (context.self.title_from_terminal == null and context.self.title_text != null) { - context.self.title_from_terminal = context.self.app.core_app.alloc.dupeZ(u8, context.self.title_text.?) catch |err| switch (err) { + if (self.title_from_terminal == null and self.title_text != null) { + self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { error.OutOfMemory => { log.err("Failed to allocate memory for title: {}", .{err}); - context.self.app.core_app.alloc.destroy(context); c.gtk_window_destroy(@ptrCast(dialog)); return; }, }; } - context.self.setTitle(title, .USER) catch {}; + self.setTitle(title, .user) catch {}; } } - context.self.app.core_app.alloc.destroy(context); c.gtk_window_destroy(@ptrCast(dialog)); } diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index d45997d6c..82452bb53 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -57,7 +57,7 @@ pub const ui_files = [_][]const u8{ "menu-window-titlebar_menu", "menu-surface-context_menu", }; -pub const blueprint_files = [_][]const u8{}; +pub const blueprint_files = [_][]const u8{"prompt-title-dialog"}; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp new file mode 100644 index 000000000..cdc7fb42c --- /dev/null +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -0,0 +1,9 @@ +using Gtk 4.0; +using Adw 1; + +Adw.AlertDialog prompt_title_dialog { + heading: "Change Terminal Title"; + body: "Leave blank to restore the default title."; + + extra-child: Entry title_entry {}; +} \ No newline at end of file From 454a53b3f138d8f317db681a140c60252bff8b61 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Fri, 21 Feb 2025 21:59:53 +0100 Subject: [PATCH 14/35] code review: - remove the menu entry defined in code --- src/apprt/gtk/App.zig | 91 ------------------------------------------- 1 file changed, 91 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 481e35e20..e9434bb5b 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1776,97 +1776,6 @@ fn initActions(self: *App) void { } } -<<<<<<< HEAD -======= -/// Initializes and populates the provided GMenu with sections and actions. -/// This function is used to set up the application's menu structure, either for -/// the main menu button or as a context menu when window decorations are disabled. -fn initMenuContent(menu: *c.GMenu) void { - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "New Window", "win.new_window"); - c.g_menu_append(section, "New Tab", "win.new_tab"); - c.g_menu_append(section, "Close Tab", "win.close_tab"); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); - c.g_menu_append(section, "Close Window", "win.close"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); - c.g_menu_append(section, "Open Configuration", "app.open-config"); - c.g_menu_append(section, "Reload Configuration", "app.reload-config"); - c.g_menu_append(section, "About Ghostty", "win.about"); - } -} - -/// This sets the self.menu property to the application menu that can be -/// shared by all application windows. -fn initMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - initMenuContent(@ptrCast(menu)); - self.menu = menu; -} - -fn initContextMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Copy", "win.copy"); - c.g_menu_append(section, "Paste", "win.paste"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Split Right", "win.split_right"); - c.g_menu_append(section, "Split Down", "win.split_down"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Change Title...", "win.prompt-title"); - } - - { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Reset", "win.reset"); - c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); - } - - const section = c.g_menu_new(); - defer c.g_object_unref(section); - const submenu = c.g_menu_new(); - defer c.g_object_unref(submenu); - - initMenuContent(@ptrCast(submenu)); - c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - - self.context_menu = menu; -} - -pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { - const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); - c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); -} - ->>>>>>> a9ae01e38 (Implement a prompt that allows the user to set the title) fn isValidAppId(app_id: [:0]const u8) bool { if (app_id.len > 255 or app_id.len == 0) return false; if (app_id[0] == '.') return false; From cd287b4161633e1c45b0042aecd79c23cd77f7a6 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Fri, 21 Feb 2025 22:11:32 +0100 Subject: [PATCH 15/35] - remove the unused dialog context struct - set the current title in the input buffer - fix formatting --- src/apprt/gtk/Surface.zig | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 589e32ef1..c31c173e2 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1019,11 +1019,6 @@ fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 { title[zoom_title_prefix.len..]; } -const PromptTitleDialogContext = struct { - entry: *c.GtkWidget, - self: *Surface, -}; - pub fn promptTitle(self: *Surface) !void { const window = self.container.window() orelse return; @@ -1033,6 +1028,8 @@ pub fn promptTitle(self: *Surface) !void { const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); dialog.addResponse("cancel", "Cancel"); dialog.addResponse("ok", "OK"); + const entry: *gtk.Entry = @ptrCast(builder.getObject("title_entry")); + entry.getBuffer().setText(self.getTitle() orelse "", -1); dialog.choose(@ptrCast(window.window), null, @ptrCast(>kPromptTitleResponse), self); } From 1ee8dfc99c25898acea082a4ae56809c7b792512 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Sat, 22 Feb 2025 11:57:41 +0100 Subject: [PATCH 16/35] code review: - remove unnecessary @ptrCast - set the default focus to the text entry field --- src/apprt/gtk/Surface.zig | 8 ++++++-- src/apprt/gtk/ui/prompt-title-dialog.blp | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c31c173e2..5dab3ec32 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -7,6 +7,7 @@ const std = @import("std"); const adw = @import("adw"); const gtk = @import("gtk"); const gio = @import("gio"); +const gobject = @import("gobject"); const glib = @import("glib"); const Allocator = std.mem.Allocator; const build_config = @import("../../build_config.zig"); @@ -1028,10 +1029,12 @@ pub fn promptTitle(self: *Surface) !void { const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); dialog.addResponse("cancel", "Cancel"); dialog.addResponse("ok", "OK"); + dialog.setResponseAppearance("ok", adw.ResponseAppearance.suggested); + const entry: *gtk.Entry = @ptrCast(builder.getObject("title_entry")); entry.getBuffer().setText(self.getTitle() orelse "", -1); - dialog.choose(@ptrCast(window.window), null, @ptrCast(>kPromptTitleResponse), self); + dialog.choose(@ptrCast(window.window), null, >kPromptTitleResponse, self); } /// Set the current working directory of the surface. @@ -2321,7 +2324,8 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { return false; } -fn gtkPromptTitleResponse(dialog: *adw.AlertDialog, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { +fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + const dialog: *adw.AlertDialog = @ptrCast(source_object.?); const self = userdataSelf(ud.?); const response = dialog.chooseFinish(result); diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp index cdc7fb42c..4dd091c52 100644 --- a/src/apprt/gtk/ui/prompt-title-dialog.blp +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -2,8 +2,9 @@ using Gtk 4.0; using Adw 1; Adw.AlertDialog prompt_title_dialog { - heading: "Change Terminal Title"; - body: "Leave blank to restore the default title."; + heading: "Change Terminal Title"; + body: "Leave blank to restore the default title."; + focus-widget: title_entry; - extra-child: Entry title_entry {}; -} \ No newline at end of file + extra-child: Entry title_entry {}; +} From 8758295647d479e44096a91ac50c2565f9d71139 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:05:02 +0100 Subject: [PATCH 17/35] code review: - move responses definition to the blueprint, use translatable strings - minor changes in the response callback --- src/apprt/gtk/Surface.zig | 24 ++++++++++-------------- src/apprt/gtk/ui/prompt-title-dialog.blp | 8 ++++++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5dab3ec32..00799da6f 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -8,7 +8,6 @@ const adw = @import("adw"); const gtk = @import("gtk"); const gio = @import("gio"); const gobject = @import("gobject"); -const glib = @import("glib"); const Allocator = std.mem.Allocator; const build_config = @import("../../build_config.zig"); const build_options = @import("build_options"); @@ -1026,14 +1025,10 @@ pub fn promptTitle(self: *Surface) !void { var builder = Builder.init("prompt-title-dialog", .blp); defer builder.deinit(); - const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); - dialog.addResponse("cancel", "Cancel"); - dialog.addResponse("ok", "OK"); - dialog.setResponseAppearance("ok", adw.ResponseAppearance.suggested); - const entry: *gtk.Entry = @ptrCast(builder.getObject("title_entry")); entry.getBuffer().setText(self.getTitle() orelse "", -1); + const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); dialog.choose(@ptrCast(window.window), null, >kPromptTitleResponse, self); } @@ -2329,14 +2324,16 @@ fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncRes const self = userdataSelf(ud.?); const response = dialog.chooseFinish(result); - if (glib.strEqual("ok", response) != 0) { - const title_entry: *gtk.Entry = @ptrCast(dialog.getExtraChild()); + if (std.mem.orderZ(u8, "ok", response) == .eq) { + const title_entry: *gtk.Entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; const title = std.mem.span(title_entry.getBuffer().getText()); // if the new title is empty and the user has set the title previously, restore the terminal provided title - if (title.len == 0 and self.title_from_terminal != null) { + if (title.len == 0) { if (self.getTerminalTitle()) |terminal_title| { - self.setTitle(terminal_title, .user) catch {}; + self.setTitle(terminal_title, .user) catch |err| { + log.err("Failed to set title: {}", .{err}); + }; self.app.core_app.alloc.free(self.title_from_terminal.?); self.title_from_terminal = null; } @@ -2346,15 +2343,14 @@ fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncRes self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { error.OutOfMemory => { log.err("Failed to allocate memory for title: {}", .{err}); - c.gtk_window_destroy(@ptrCast(dialog)); return; }, }; } - self.setTitle(title, .user) catch {}; + self.setTitle(title, .user) catch |err| { + log.err("Failed to set title: {}", .{err}); + }; } } - - c.gtk_window_destroy(@ptrCast(dialog)); } diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp index 4dd091c52..3187eccc5 100644 --- a/src/apprt/gtk/ui/prompt-title-dialog.blp +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -2,8 +2,12 @@ using Gtk 4.0; using Adw 1; Adw.AlertDialog prompt_title_dialog { - heading: "Change Terminal Title"; - body: "Leave blank to restore the default title."; + heading: _("Change Terminal Title"); + body: _("Leave blank to restore the default title."); + responses [ + cancel: _("Cancel"), + ok: _("OK") suggested + ] focus-widget: title_entry; extra-child: Entry title_entry {}; From 7c19dd5a3322f6caedd30e676d78439f21c3debd Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:14:46 +0100 Subject: [PATCH 18/35] format the blueprint file using `blueprint-compiler format` --- src/apprt/gtk/ui/prompt-title-dialog.blp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp index 3187eccc5..7cd5a2657 100644 --- a/src/apprt/gtk/ui/prompt-title-dialog.blp +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -4,10 +4,12 @@ using Adw 1; Adw.AlertDialog prompt_title_dialog { heading: _("Change Terminal Title"); body: _("Leave blank to restore the default title."); + responses [ cancel: _("Cancel"), ok: _("OK") suggested ] + focus-widget: title_entry; extra-child: Entry title_entry {}; From 4f3c4037aa35914af9633cbf7d051c9d9f85b65d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 12:54:08 -0600 Subject: [PATCH 19/35] gtk: get 'Change Title' working with older distros --- src/apprt/gtk/Builder.zig | 2 +- src/apprt/gtk/Surface.zig | 15 ++++--- src/apprt/gtk/blueprint_compiler.zig | 57 ++++++++++++++++++++++++ src/apprt/gtk/gresource.zig | 20 ++++++--- src/apprt/gtk/ui/prompt-title-dialog.blp | 4 +- src/build/SharedDeps.zig | 24 +++++++--- src/build/docker/debian/Dockerfile | 2 + 7 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 src/apprt/gtk/blueprint_compiler.zig diff --git a/src/apprt/gtk/Builder.zig b/src/apprt/gtk/Builder.zig index f9b0c226a..473abc0f7 100644 --- a/src/apprt/gtk/Builder.zig +++ b/src/apprt/gtk/Builder.zig @@ -25,7 +25,7 @@ pub fn init(comptime name: []const u8, comptime kind: enum { blp, ui }) Builder // GResource. const gresource = @import("gresource.zig"); for (gresource.blueprint_files) |blueprint_file| { - if (std.mem.eql(u8, blueprint_file, name)) break; + if (std.mem.eql(u8, blueprint_file.name, name)) break; } else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig"); }, .ui => { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 00799da6f..9836e7fbf 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -31,6 +31,7 @@ const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; const Builder = @import("Builder.zig"); +const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk_surface); @@ -1020,16 +1021,17 @@ fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 { } pub fn promptTitle(self: *Surface) !void { + if (!adwaita.versionAtLeast(1, 5, 0)) return; const window = self.container.window() orelse return; var builder = Builder.init("prompt-title-dialog", .blp); defer builder.deinit(); - const entry: *gtk.Entry = @ptrCast(builder.getObject("title_entry")); + const entry = gobject.ext.cast(gtk.Entry, builder.getObject("title_entry").?).?; entry.getBuffer().setText(self.getTitle() orelse "", -1); - const dialog: *adw.AlertDialog = @ptrCast(builder.getObject("prompt_title_dialog")); - dialog.choose(@ptrCast(window.window), null, >kPromptTitleResponse, self); + const dialog = gobject.ext.cast(adw.AlertDialog, builder.getObject("prompt_title_dialog").?).?; + dialog.choose(@ptrCast(window.window), null, gtkPromptTitleResponse, self); } /// Set the current working directory of the surface. @@ -2320,12 +2322,13 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { } fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { - const dialog: *adw.AlertDialog = @ptrCast(source_object.?); - const self = userdataSelf(ud.?); + if (!adwaita.versionAtLeast(1, 5, 0)) return; + const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?; + const self = userdataSelf(ud orelse return); const response = dialog.chooseFinish(result); if (std.mem.orderZ(u8, "ok", response) == .eq) { - const title_entry: *gtk.Entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; + const title_entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; const title = std.mem.span(title_entry.getBuffer().getText()); // if the new title is empty and the user has set the title previously, restore the terminal provided title diff --git a/src/apprt/gtk/blueprint_compiler.zig b/src/apprt/gtk/blueprint_compiler.zig new file mode 100644 index 000000000..f1d42c43d --- /dev/null +++ b/src/apprt/gtk/blueprint_compiler.zig @@ -0,0 +1,57 @@ +const std = @import("std"); + +pub const c = @cImport({ + @cInclude("adwaita.h"); +}); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const alloc = gpa.allocator(); + + var it = try std.process.argsWithAllocator(alloc); + defer it.deinit(); + + _ = it.next(); + + const major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10); + const minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10); + const micro = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMicroVersion, 10); + const output = it.next() orelse return error.NoOutput; + const input = it.next() orelse return error.NoInput; + + if (c.ADW_MAJOR_VERSION < major or + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro)) + { + // If the Adwaita version is too old, generate an "empty" file. + const file = try std.fs.createFileAbsolute(output, .{ + .truncate = true, + }); + try file.writeAll( + \\ + \\ + ); + defer file.close(); + + return; + } + + var compiler = std.process.Child.init( + &.{ + "blueprint-compiler", + "compile", + "--output", + output, + input, + }, + alloc, + ); + + const term = try compiler.spawnAndWait(); + switch (term) { + .Exited => |rc| { + if (rc != 0) std.posix.exit(1); + }, + else => std.posix.exit(1), + } +} diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index 82452bb53..83978c337 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -57,7 +57,17 @@ pub const ui_files = [_][]const u8{ "menu-window-titlebar_menu", "menu-surface-context_menu", }; -pub const blueprint_files = [_][]const u8{"prompt-title-dialog"}; + +pub const VersionedBlueprint = struct { + major: u16, + minor: u16, + micro: u16, + name: []const u8, +}; + +pub const blueprint_files = [_]VersionedBlueprint{ + .{ .major = 1, .minor = 5, .micro = 0, .name = "prompt-title-dialog" }, +}; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -72,9 +82,9 @@ pub fn main() !void { var it = try std.process.argsWithAllocator(alloc); defer it.deinit(); - while (it.next()) |filename| { - if (std.mem.eql(u8, std.fs.path.extension(filename), ".ui")) { - try extra_ui_files.append(try alloc.dupe(u8, filename)); + while (it.next()) |argument| { + if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) { + try extra_ui_files.append(try alloc.dupe(u8, argument)); } } @@ -144,7 +154,7 @@ pub const dependencies = deps: { index += 1; } for (blueprint_files) |blueprint_file| { - deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.blp", .{blueprint_file}); + deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.blp", .{blueprint_file.name}); index += 1; } break :deps deps; diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp index 7cd5a2657..ffe38c980 100644 --- a/src/apprt/gtk/ui/prompt-title-dialog.blp +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -6,8 +6,8 @@ Adw.AlertDialog prompt_title_dialog { body: _("Leave blank to restore the default title."); responses [ - cancel: _("Cancel"), - ok: _("OK") suggested + cancel: _("Cancel") suggested, + ok: _("OK") destructive ] focus-widget: title_entry; diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index a90fc330a..65b2b47da 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -443,6 +443,7 @@ pub fn add( .{ "glib", "glib2" }, .{ "gtk", "gtk4" }, .{ "gdk", "gdk4" }, + .{ "adw", "adw1" }, }; inline for (gobject_imports) |import| { const name, const module = import; @@ -451,7 +452,6 @@ pub fn add( step.linkSystemLibrary2("gtk4", dynamic_link_opts); step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); - step.root_module.addImport("adw", gobject.module("adw1")); if (self.config.x11) { step.linkSystemLibrary2("X11", dynamic_link_opts); @@ -500,14 +500,24 @@ pub fn add( const generate = b.addRunArtifact(generate_gresource_xml); + const gtk_blueprint_compiler = b.addExecutable(.{ + .name = "gtk_blueprint_compiler", + .root_source_file = b.path("src/apprt/gtk/blueprint_compiler.zig"), + .target = b.host, + }); + gtk_blueprint_compiler.linkSystemLibrary2("gtk4", dynamic_link_opts); + gtk_blueprint_compiler.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); + gtk_blueprint_compiler.linkLibC(); + for (gresource.blueprint_files) |blueprint_file| { - const blueprint_compiler = b.addSystemCommand(&.{ - "blueprint-compiler", - "compile", - "--output", + const blueprint_compiler = b.addRunArtifact(gtk_blueprint_compiler); + blueprint_compiler.addArgs(&.{ + b.fmt("{d}", .{blueprint_file.major}), + b.fmt("{d}", .{blueprint_file.minor}), + b.fmt("{d}", .{blueprint_file.micro}), }); - const ui_file = blueprint_compiler.addOutputFileArg(b.fmt("{s}.ui", .{blueprint_file})); - blueprint_compiler.addFileArg(b.path(b.fmt("src/apprt/gtk/ui/{s}.blp", .{blueprint_file}))); + const ui_file = blueprint_compiler.addOutputFileArg(b.fmt("{s}.ui", .{blueprint_file.name})); + blueprint_compiler.addFileArg(b.path(b.fmt("src/apprt/gtk/ui/{s}.blp", .{blueprint_file.name}))); generate.addFileArg(ui_file); } diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index 307fb7521..7f60ddf1d 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -5,9 +5,11 @@ FROM docker.io/library/debian:${DISTRO_VERSION} RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ apt-get -qq -y --no-install-recommends install \ # Build Tools + blueprint-compiler \ build-essential \ libbz2-dev \ libonig-dev \ + libxml2-utils \ lintian \ lsb-release \ libxml2-utils \ From 51dc1e2e8cf4adaa9bf0e1d12b1031a8b99e631f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 13:05:54 -0600 Subject: [PATCH 20/35] gtk: fix typos --- src/apprt/gtk/Surface.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9836e7fbf..80039522b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -353,9 +353,10 @@ cursor: ?*c.GdkCursor = null, /// pass it to GTK. title_text: ?[:0]const u8 = null, -/// The title of the surface as reported by the terminal. -/// If it is null, the title reported by the terminal is currently being used. -/// If the title was manually overriden by the user, this will be set to a non-null value representing the default terminal title. +/// The title of the surface as reported by the terminal. If it is null, the +/// title reported by the terminal is currently being used. If the title was +/// manually overridden by the user, this will be set to a non-null value +/// representing the default terminal title. title_from_terminal: ?[:0]const u8 = null, /// Our current working directory. We use this value for setting tooltips in @@ -967,7 +968,7 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !vo }; errdefer alloc.free(copy); - // The user has overriden the title + // The user has overridden the title // We only want to update the terminal provided title so that it can be restored to the most recent state. if (self.title_from_terminal != null and source == .terminal) { alloc.free(self.title_from_terminal.?); From d4bcac0150c5d2293f6317d1009f21263bd16c72 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 13:25:14 -0600 Subject: [PATCH 21/35] snap: add blueprint-compiler to snap --- snap/snapcraft.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9ef2f5cc4..ddd38b30d 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -70,6 +70,7 @@ parts: plugin: nil build-attributes: [enable-patchelf] build-packages: + - blueprint-compiler - libgtk-4-dev - libadwaita-1-dev - git From 32a62ff862486f1616a33e7498a4fda022e17f8e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 13:36:32 -0600 Subject: [PATCH 22/35] snap: add libxml2-utils (for xmllint) to snap --- snap/snapcraft.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ddd38b30d..49d452ef9 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -73,6 +73,7 @@ parts: - blueprint-compiler - libgtk-4-dev - libadwaita-1-dev + - libxml2-utils - git - patchelf override-build: | From 5d80db2ef8f05423437d8d0113223d13dcab4e41 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:39:27 +0100 Subject: [PATCH 23/35] code review: fix log format --- src/apprt/gtk/Surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 80039522b..15e33aee3 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2336,7 +2336,7 @@ fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncRes if (title.len == 0) { if (self.getTerminalTitle()) |terminal_title| { self.setTitle(terminal_title, .user) catch |err| { - log.err("Failed to set title: {}", .{err}); + log.err("failed to set title={}", .{err}); }; self.app.core_app.alloc.free(self.title_from_terminal.?); self.title_from_terminal = null; @@ -2346,14 +2346,14 @@ fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncRes if (self.title_from_terminal == null and self.title_text != null) { self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { error.OutOfMemory => { - log.err("Failed to allocate memory for title: {}", .{err}); + log.err("failed to allocate memory for title={}", .{err}); return; }, }; } self.setTitle(title, .user) catch |err| { - log.err("Failed to set title: {}", .{err}); + log.err("failed to set title={}", .{err}); }; } } From bde5b963d0117afc700c47dd00d0b5fdf1e852d0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 16:38:09 -0600 Subject: [PATCH 24/35] gtk: fix Builder api changes --- src/apprt/gtk/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 15e33aee3..2636b41aa 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1028,10 +1028,10 @@ pub fn promptTitle(self: *Surface) !void { var builder = Builder.init("prompt-title-dialog", .blp); defer builder.deinit(); - const entry = gobject.ext.cast(gtk.Entry, builder.getObject("title_entry").?).?; + const entry = builder.getObject(gtk.Entry, "title_entry").?; entry.getBuffer().setText(self.getTitle() orelse "", -1); - const dialog = gobject.ext.cast(adw.AlertDialog, builder.getObject("prompt_title_dialog").?).?; + const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?; dialog.choose(@ptrCast(window.window), null, gtkPromptTitleResponse, self); } From f1134640c5f3bcf7882d87980fcc0f6da2084d27 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 17:42:09 -0600 Subject: [PATCH 25/35] gtk: update ResizeOverlay for zig-gobject Also switch to a "DerivedConfig" model so that we aren't referring to a global copy of the config. --- src/apprt/gtk/ResizeOverlay.zig | 158 +++++++++++++++++--------------- src/apprt/gtk/Surface.zig | 18 ++-- 2 files changed, 96 insertions(+), 80 deletions(-) diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig index 108dcd676..7a02e2bc4 100644 --- a/src/apprt/gtk/ResizeOverlay.zig +++ b/src/apprt/gtk/ResizeOverlay.zig @@ -1,24 +1,45 @@ const ResizeOverlay = @This(); const std = @import("std"); -const c = @import("c.zig").c; + +const glib = @import("glib"); +const gtk = @import("gtk"); + const configpkg = @import("../../config.zig"); const Surface = @import("Surface.zig"); const log = std.log.scoped(.gtk); -/// Back reference to the surface we belong to -surface: ?*Surface = null, +/// local copy of configuration data +const DerivedConfig = struct { + resize_overlay: configpkg.Config.ResizeOverlay, + resize_overlay_position: configpkg.Config.ResizeOverlayPosition, + resize_overlay_duration: configpkg.Config.Duration, + + pub fn init(config: *const configpkg.Config) DerivedConfig { + return .{ + .resize_overlay = config.@"resize-overlay", + .resize_overlay_position = config.@"resize-overlay-position", + .resize_overlay_duration = config.@"resize-overlay-duration", + }; + } +}; + +/// the surface that we are attached to +surface: *Surface, + +/// a copy of the configuration that we need to operate +config: DerivedConfig, /// If non-null this is the widget on the overlay that shows the size of the /// surface when it is resized. -widget: ?*c.GtkWidget = null, +label: ?*gtk.Label = null, /// If non-null this is a timer for dismissing the resize overlay. -timer: ?c.guint = null, +timer: ?c_uint = null, /// If non-null this is a timer for dismissing the resize overlay. -idler: ?c.guint = null, +idler: ?c_uint = null, /// If true, the next resize event will be the first one. first: bool = true, @@ -26,24 +47,29 @@ first: bool = true, /// Initialize the ResizeOverlay. This doesn't do anything more than save a /// pointer to the surface that we are a part of as all of the widget creation /// is done later. -pub fn init(surface: *Surface) ResizeOverlay { - return .{ +pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { + self.* = .{ .surface = surface, + .config = DerivedConfig.init(config), }; } +pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { + self.config = DerivedConfig.init(config); +} + /// De-initialize the ResizeOverlay. This removes any pending idlers/timers that /// may not have fired yet. pub fn deinit(self: *ResizeOverlay) void { if (self.idler) |idler| { - if (c.g_source_remove(idler) == c.FALSE) { + if (glib.Source.remove(idler) == 0) { log.warn("unable to remove resize overlay idler", .{}); } self.idler = null; } if (self.timer) |timer| { - if (c.g_source_remove(timer) == c.FALSE) { + if (glib.Source.remove(timer) == 0) { log.warn("unable to remove resize overlay timer", .{}); } self.timer = null; @@ -56,12 +82,7 @@ pub fn deinit(self: *ResizeOverlay) void { /// /// If we're not configured to show the overlay, do nothing. pub fn maybeShow(self: *ResizeOverlay) void { - const surface = self.surface orelse { - log.err("resize overlay configured without a surface", .{}); - return; - }; - - switch (surface.app.config.@"resize-overlay") { + switch (self.config.resize_overlay) { .never => return, .always => {}, .@"after-first" => if (self.first) { @@ -78,23 +99,18 @@ pub fn maybeShow(self: *ResizeOverlay) void { // results in a lot of warnings from GTK and _horrible_ flickering of the // resize overlay. if (self.idler != null) return; - self.idler = c.g_idle_add(gtkUpdate, @ptrCast(self)); + self.idler = glib.idleAdd(gtkUpdate, self); } /// Actually update the overlay widget. This should only be called from a GTK /// idle handler. -fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c.gboolean { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud)); +fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c_int { + const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); // No matter what our idler is complete with this callback self.idler = null; - const surface = self.surface orelse { - log.err("resize overlay configured without a surface", .{}); - return c.FALSE; - }; - - const grid_size = surface.core_surface.size.grid(); + const grid_size = self.surface.core_surface.size.grid(); var buf: [32]u8 = undefined; const text = std.fmt.bufPrintZ( &buf, @@ -105,88 +121,86 @@ fn gtkUpdate(ud: ?*anyopaque) callconv(.C) c.gboolean { }, ) catch |err| { log.err("unable to format text: {}", .{err}); - return c.FALSE; + return 0; }; - if (self.widget) |widget| { + if (self.label) |label| { // The resize overlay widget already exists, just update it. - c.gtk_label_set_text(@ptrCast(widget), text.ptr); - setPosition(widget, &surface.app.config); - show(widget); + label.setText(text.ptr); + setPosition(label, &self.config); + show(label); } else { // Create the resize overlay widget. - const widget = c.gtk_label_new(text.ptr); + const label = gtk.Label.new(text.ptr); + label.setJustify(gtk.Justification.center); + label.setSelectable(0); + setPosition(label, &self.config); - c.gtk_widget_add_css_class(widget, "view"); - c.gtk_widget_add_css_class(widget, "size-overlay"); - c.gtk_widget_set_focusable(widget, c.FALSE); - c.gtk_widget_set_can_target(widget, c.FALSE); - c.gtk_label_set_justify(@ptrCast(widget), c.GTK_JUSTIFY_CENTER); - c.gtk_label_set_selectable(@ptrCast(widget), c.FALSE); - setPosition(widget, &surface.app.config); + const widget = label.as(gtk.Widget); + widget.addCssClass("view"); + widget.addCssClass("size-overlay"); + widget.setFocusable(0); + widget.setCanTarget(0); - c.gtk_overlay_add_overlay(surface.overlay, widget); + const overlay: *gtk.Overlay = @ptrCast(@alignCast(self.surface.overlay)); + overlay.addOverlay(widget); - self.widget = widget; + self.label = label; } if (self.timer) |timer| { - if (c.g_source_remove(timer) == c.FALSE) { + if (glib.Source.remove(timer) == 0) { log.warn("unable to remove size overlay timer", .{}); } } - self.timer = c.g_timeout_add( - surface.app.config.@"resize-overlay-duration".asMilliseconds(), + + self.timer = glib.timeoutAdd( + self.surface.app.config.@"resize-overlay-duration".asMilliseconds(), gtkTimerExpired, - @ptrCast(self), + self, ); - return c.FALSE; + return 0; } // This should only be called from a GTK idle handler or timer. -fn show(widget: *c.GtkWidget) void { - // The CSS class is used only by libadwaita. - c.gtk_widget_remove_css_class(@ptrCast(widget), "hidden"); - // Set the visibility for non-libadwaita usage. - c.gtk_widget_set_visible(@ptrCast(widget), 1); +fn show(label: *gtk.Label) void { + const widget = label.as(gtk.Widget); + widget.removeCssClass("hidden"); } // This should only be called from a GTK idle handler or timer. -fn hide(widget: *c.GtkWidget) void { - // The CSS class is used only by libadwaita. - c.gtk_widget_add_css_class(widget, "hidden"); - // Set the visibility for non-libadwaita usage. - c.gtk_widget_set_visible(widget, c.FALSE); +fn hide(label: *gtk.Label) void { + const widget = label.as(gtk.Widget); + widget.addCssClass("hidden"); } /// Update the position of the resize overlay widget. It might seem excessive to /// do this often, but it should make hot config reloading of the position work. /// This should only be called from a GTK idle handler. -fn setPosition(widget: *c.GtkWidget, config: *configpkg.Config) void { - c.gtk_widget_set_halign( - @ptrCast(widget), - switch (config.@"resize-overlay-position") { - .center, .@"top-center", .@"bottom-center" => c.GTK_ALIGN_CENTER, - .@"top-left", .@"bottom-left" => c.GTK_ALIGN_START, - .@"top-right", .@"bottom-right" => c.GTK_ALIGN_END, +fn setPosition(label: *gtk.Label, config: *DerivedConfig) void { + const widget = label.as(gtk.Widget); + widget.setHalign( + switch (config.resize_overlay_position) { + .center, .@"top-center", .@"bottom-center" => gtk.Align.center, + .@"top-left", .@"bottom-left" => gtk.Align.start, + .@"top-right", .@"bottom-right" => gtk.Align.end, }, ); - c.gtk_widget_set_valign( - @ptrCast(widget), - switch (config.@"resize-overlay-position") { - .center => c.GTK_ALIGN_CENTER, - .@"top-left", .@"top-center", .@"top-right" => c.GTK_ALIGN_START, - .@"bottom-left", .@"bottom-center", .@"bottom-right" => c.GTK_ALIGN_END, + widget.setValign( + switch (config.resize_overlay_position) { + .center => gtk.Align.center, + .@"top-left", .@"top-center", .@"top-right" => gtk.Align.start, + .@"bottom-left", .@"bottom-center", .@"bottom-right" => gtk.Align.end, }, ); } /// If this fires, it means that the delay period has expired and the resize /// overlay widget should be hidden. -fn gtkTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud)); +fn gtkTimerExpired(ud: ?*anyopaque) callconv(.C) c_int { + const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); self.timer = null; - if (self.widget) |widget| hide(widget); - return c.FALSE; + if (self.label) |label| hide(label); + return 0; } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index a146bba15..3c94989c2 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -273,8 +273,8 @@ pub const URLWidget = struct { ); // Show it - c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), left); - c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), right); + c.gtk_overlay_add_overlay(surface.overlay, left); + c.gtk_overlay_add_overlay(surface.overlay, right); return .{ .left = left, @@ -283,8 +283,8 @@ pub const URLWidget = struct { } pub fn deinit(self: *URLWidget, overlay: *c.GtkOverlay) void { - c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.left)); - c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.right)); + c.gtk_overlay_remove_overlay(overlay, @ptrCast(self.left)); + c.gtk_overlay_remove_overlay(overlay, @ptrCast(self.right)); } pub fn setText(self: *const URLWidget, str: [:0]const u8) void { @@ -336,7 +336,7 @@ gl_area: *c.GtkGLArea, url_widget: ?URLWidget = null, /// The overlay that shows resizing information. -resize_overlay: ResizeOverlay = .{}, +resize_overlay: ResizeOverlay = undefined, /// Whether or not the current surface is zoomed in (see `toggle_split_zoom`). zoomed_in: bool = false, @@ -583,7 +583,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .container = .{ .none = {} }, .overlay = @ptrCast(overlay), .gl_area = @ptrCast(gl_area), - .resize_overlay = ResizeOverlay.init(self), + .resize_overlay = undefined, .title_text = null, .core_surface = undefined, .font_size = font_size, @@ -600,6 +600,9 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { self.context_menu.init(self); self.context_menu.setParent(@ptrCast(@alignCast(overlay))); + // initialize the resize overlay + self.resize_overlay.init(self, &app.config); + // Set our default mouse shape try self.setMouseShape(.text); @@ -706,8 +709,7 @@ pub fn deinit(self: *Surface) void { /// Update our local copy of any configuration that we use. pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void { - _ = self; - _ = config; + self.resize_overlay.updateConfig(config); } // unref removes the long-held reference to the gl_area and kicks off the From 573fe7348b1a57a740a0aaeec7b39e87a2199bc1 Mon Sep 17 00:00:00 2001 From: taylrfnt Date: Sat, 22 Feb 2025 17:46:30 -0600 Subject: [PATCH 26/35] add macos default config path to manpages --- src/build/mdgen/ghostty_1_footer.md | 8 ++++++++ src/build/mdgen/ghostty_5_footer.md | 8 ++++++++ src/build/mdgen/ghostty_5_header.md | 5 +++++ 3 files changed, 21 insertions(+) diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 86a8a8098..bd261a68b 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -4,6 +4,10 @@ _\$XDG_CONFIG_HOME/ghostty/config_ : Location of the default configuration file. +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ + +: **On macOS**, location of the default configuration file. + _\$LOCALAPPDATA/ghostty/config_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched @@ -23,6 +27,10 @@ for configuration files. : Default location for configuration files. +**$HOME/Library/Application Support/com.mitchellh.ghostty** + +: **MACOS ONLY** default location for configuration files. + **LOCALAPPDATA** : **WINDOWS ONLY:** alternate location to search for configuration files. diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index 0c893dd07..cdf126b81 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -4,6 +4,10 @@ _\$XDG_CONFIG_HOME/ghostty/config_ : Location of the default configuration file. +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ + +: **On macOS**, location of the default configuration file. + _\$LOCALAPPDATA/ghostty/config_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched @@ -15,6 +19,10 @@ for configuration files. : Default location for configuration files. +**$HOME/Library/Application Support/com.mitchellh.ghostty** + +: **MACOS ONLY** default location for configuration files. + **LOCALAPPDATA** : **WINDOWS ONLY:** alternate location to search for configuration files. diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index aac6c17ea..078133861 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -11,6 +11,11 @@ is on the roadmap but not yet supported. The configuration file must be placed at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to `~/.config/ghostty/config` if the [XDG environment is not set](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). +**If you are using macOS, the configuration file can also be placed at +`$HOME/Library/Application Support/com.mitchellh.ghostty/config`.** This is the +default configuration location for macOS. It will be searched before any of the +XDG environment locations listed above. + The file format is documented below as an example: # The syntax is "key = value". The whitespace around the equals doesn't matter. From eec150d4cdc7e473bca256f64a989cb84c8b2401 Mon Sep 17 00:00:00 2001 From: taylrfnt Date: Sat, 22 Feb 2025 17:58:05 -0600 Subject: [PATCH 27/35] mention default loc precendence over XDG --- src/build/mdgen/ghostty_1_footer.md | 3 ++- src/build/mdgen/ghostty_5_footer.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index bd261a68b..a0516b4c2 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -6,7 +6,8 @@ _\$XDG_CONFIG_HOME/ghostty/config_ _\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ -: **On macOS**, location of the default configuration file. +: **On macOS**, location of the default configuration file. This location takes +precedence over the XDG environment locations. _\$LOCALAPPDATA/ghostty/config_ diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index cdf126b81..d94ef9b55 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -6,7 +6,8 @@ _\$XDG_CONFIG_HOME/ghostty/config_ _\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ -: **On macOS**, location of the default configuration file. +: **On macOS**, location of the default configuration file. This location takes +precedence over the XDG environment locations. _\$LOCALAPPDATA/ghostty/config_ From 398add17f11758c541e575360e024cdb0f33a053 Mon Sep 17 00:00:00 2001 From: taylrfnt Date: Sat, 22 Feb 2025 18:00:37 -0600 Subject: [PATCH 28/35] actually change it in both places --- src/build/mdgen/ghostty_1_footer.md | 3 ++- src/build/mdgen/ghostty_5_footer.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index a0516b4c2..7ace64cd8 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -30,7 +30,8 @@ for configuration files. **$HOME/Library/Application Support/com.mitchellh.ghostty** -: **MACOS ONLY** default location for configuration files. +: **MACOS ONLY** default location for configuration files. This location takes +precedence over the XDG environment locations. **LOCALAPPDATA** diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index d94ef9b55..c5077ab97 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -22,7 +22,8 @@ for configuration files. **$HOME/Library/Application Support/com.mitchellh.ghostty** -: **MACOS ONLY** default location for configuration files. +: **MACOS ONLY** default location for configuration files. This location takes +precedence over the XDG environment locations. **LOCALAPPDATA** From 0af256b57a381fc419bef89b5632c023a0d9e54a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 18:23:30 -0600 Subject: [PATCH 29/35] gtk: fix the alignment of the context menu This aligns the top left of the context menu with the right-click location, rather than the top center. --- src/apprt/gtk/menu.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig index c36ace646..92b636280 100644 --- a/src/apprt/gtk/menu.zig +++ b/src/apprt/gtk/menu.zig @@ -47,6 +47,7 @@ pub fn Menu( const menu_model = builder.getObject(gio.MenuModel, "menu").?; const menu_widget = gtk.PopoverMenu.newFromModelFull(menu_model, .{ .nested = true }); + menu_widget.as(gtk.Widget).setHalign(.start); menu_widget.as(gtk.Popover).setHasArrow(@intFromBool(arrow)); _ = gtk.Popover.signals.closed.connect( menu_widget, From a8b6b96fbdcd7048444bc0ec2f72f097c1f29ef8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 20:21:41 -0600 Subject: [PATCH 30/35] gtk: switch menus to use blueprints instead of raw builder ui --- src/apprt/gtk/Window.zig | 2 +- src/apprt/gtk/gresource.zig | 7 +- src/apprt/gtk/menu.zig | 2 +- .../gtk/ui/menu-surface-context_menu.blp | 102 ++++++++++++++++ src/apprt/gtk/ui/menu-surface-context_menu.ui | 90 -------------- .../gtk/ui/menu-window-titlebar_menu.blp | 111 ++++++++++++++++++ src/apprt/gtk/ui/menu-window-titlebar_menu.ui | 93 --------------- 7 files changed, 218 insertions(+), 189 deletions(-) create mode 100644 src/apprt/gtk/ui/menu-surface-context_menu.blp delete mode 100644 src/apprt/gtk/ui/menu-surface-context_menu.ui create mode 100644 src/apprt/gtk/ui/menu-window-titlebar_menu.blp delete mode 100644 src/apprt/gtk/ui/menu-window-titlebar_menu.ui diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index cd7e650f3..5d1efd3ca 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -490,7 +490,7 @@ fn initActions(self: *Window) void { .{ "paste", >kActionPaste }, .{ "reset", >kActionReset }, .{ "clear", >kActionClear }, - .{ "prompt_title", >kActionPromptTitle }, + .{ "prompt-title", >kActionPromptTitle }, }; inline for (actions) |entry| { diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index 83978c337..4bd08ed0a 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -53,10 +53,7 @@ const icons = [_]struct { }, }; -pub const ui_files = [_][]const u8{ - "menu-window-titlebar_menu", - "menu-surface-context_menu", -}; +pub const ui_files = [_][]const u8{}; pub const VersionedBlueprint = struct { major: u16, @@ -67,6 +64,8 @@ pub const VersionedBlueprint = struct { pub const blueprint_files = [_]VersionedBlueprint{ .{ .major = 1, .minor = 5, .micro = 0, .name = "prompt-title-dialog" }, + .{ .major = 1, .minor = 0, .micro = 0, .name = "menu-surface-context_menu" }, + .{ .major = 1, .minor = 0, .micro = 0, .name = "menu-window-titlebar_menu" }, }; pub fn main() !void { diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig index 92b636280..ef70df1b7 100644 --- a/src/apprt/gtk/menu.zig +++ b/src/apprt/gtk/menu.zig @@ -41,7 +41,7 @@ pub fn Menu( else => unreachable, }; - var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, .ui); + var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, .blp); defer builder.deinit(); const menu_model = builder.getObject(gio.MenuModel, "menu").?; diff --git a/src/apprt/gtk/ui/menu-surface-context_menu.blp b/src/apprt/gtk/ui/menu-surface-context_menu.blp new file mode 100644 index 000000000..ab48552db --- /dev/null +++ b/src/apprt/gtk/ui/menu-surface-context_menu.blp @@ -0,0 +1,102 @@ +using Gtk 4.0; + +menu menu { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-title"; + } + + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/src/apprt/gtk/ui/menu-surface-context_menu.ui b/src/apprt/gtk/ui/menu-surface-context_menu.ui deleted file mode 100644 index b5bf55cef..000000000 --- a/src/apprt/gtk/ui/menu-surface-context_menu.ui +++ /dev/null @@ -1,90 +0,0 @@ - - - - -
- - Copy - win.copy - - - Paste - win.paste - -
-
- - Clear - win.clear - - - Reset - win.reset - -
-
- - Split -
- - Split Up - win.split-up - - - Split Down - win.split-down - - - Split Left - win.split-left - - - Split Right - win.split-right - -
-
- - Tab -
- - New Tab - win.new-tab - - - Close Tab - win.close-tab - -
-
- - Window -
- - New Window - win.new-window - - - Close Window - win.close - -
-
-
-
- - Config -
- - Open Configuration - app.open-config - - - Reload Configuration - app.reload-config - -
-
-
-
-
diff --git a/src/apprt/gtk/ui/menu-window-titlebar_menu.blp b/src/apprt/gtk/ui/menu-window-titlebar_menu.blp new file mode 100644 index 000000000..71e7d060c --- /dev/null +++ b/src/apprt/gtk/ui/menu-window-titlebar_menu.blp @@ -0,0 +1,111 @@ +using Gtk 4.0; + +menu menu { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-title"; + } + + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + item { + label: _("Terminal Inspector"); + action: "win.toggle-inspector"; + } + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + + section { + item { + label: _("About Ghostty"); + action: "win.about"; + } + + item { + label: _("Quit"); + action: "app.quit"; + } + } +} diff --git a/src/apprt/gtk/ui/menu-window-titlebar_menu.ui b/src/apprt/gtk/ui/menu-window-titlebar_menu.ui deleted file mode 100644 index 6359b993c..000000000 --- a/src/apprt/gtk/ui/menu-window-titlebar_menu.ui +++ /dev/null @@ -1,93 +0,0 @@ - - - - -
- - Copy - win.copy - - - Paste - win.paste - -
-
- - New Window - win.new-window - - - Close Window - win.close - -
-
- - New Tab - win.new-tab - - - Close Tab - win.close-tab - -
-
- - Split -
- - Split Up - win.split-up - - - Split Down - win.split-down - - - Split Left - win.split-left - - - Split Right - win.split-right - -
-
-
-
- - Clear - win.clear - - - Reset - win.reset - -
-
- - Terminal Inspector - win.toggle-inspector - - - Open Configuration - app.open-config - - - Reload Configuration - app.reload-config - -
-
- - About Ghostty - win.about - - - Quit - app.quit - -
-
-
From 427da79a020db1114ba92b8260a5e4aa16e945a3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 22 Feb 2025 20:23:16 -0600 Subject: [PATCH 31/35] gtk: fix menu separator colors (again) --- src/apprt/gtk/style-dark.css | 2 +- src/apprt/gtk/style.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css index dcd4bcab9..1ea2aeb4b 100644 --- a/src/apprt/gtk/style-dark.css +++ b/src/apprt/gtk/style-dark.css @@ -2,7 +2,7 @@ background-color: transparent; } -.terminal-window .notebook separator { +.terminal-window .notebook paned > separator { background-color: rgba(36, 36, 36, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index d1e848ac6..dbed8ef25 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -45,7 +45,7 @@ window.ssd.no-border-radius { background-color: transparent; } -.terminal-window .notebook separator { +.terminal-window .notebook paned > separator { background-color: rgba(250, 250, 250, 1); background-clip: content-box; } From 22c506b03e2ea2ff83d0088b7cb1e15c6e2c64f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Feb 2025 20:40:06 -0800 Subject: [PATCH 32/35] terminal: increase CSI max params to 24 to accept Kakoune sequence See #5930 Kakoune sends a real SGR sequence with 17 parameters. Our previous max was 16 so we through away the entire sequence. This commit increases the max rather than fundamentally addressing limitations. Practically, it took us this long to witness a real world sequence that exceeded our previous limit. We may need to revisit this in the future, but this is an easy fix for now. In the future, as the comment states in this diff, we should probably look into a rare slow path where we heap allocate to accept up to some larger size (but still would need a cap to avoid DoS). For now, increasing to 24 slightly increases our memory usage but shouldn't result in any real world issues. --- src/terminal/Parser.zig | 76 +++++++++++++++++++++++++++++++++++++++-- src/terminal/sgr.zig | 62 +++++++++++++++++++++++++++++---- src/terminal/stream.zig | 2 +- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index a779c3350..bc5859ede 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -86,7 +86,9 @@ pub const Action = union(enum) { final: u8, /// The list of separators used for CSI params. The value of the - /// bit can be mapped to Sep. + /// bit can be mapped to Sep. The index of this bit set specifies + /// the separator AFTER that param. For example: 0;4:3 would have + /// index 1 set. pub const SepList = std.StaticBitSet(MAX_PARAMS); /// The separator used for CSI params. @@ -192,7 +194,19 @@ pub const Action = union(enum) { /// 4 because we also use the intermediates array for UTF8 decoding which /// can be at most 4 bytes. const MAX_INTERMEDIATE = 4; -const MAX_PARAMS = 16; + +/// Maximum number of CSI parameters. This is arbitrary. Practically, the +/// only CSI command that uses more than 3 parameters is the SGR command +/// which can be infinitely long. 24 is a reasonable limit based on empirical +/// data. This used to be 16 but Kakoune has a SGR command that uses 17 +/// parameters. +/// +/// We could in the future make this the static limit and then allocate after +/// but that's a lot more work and practically its so rare to exceed this +/// number. I implore TUI authors to not use more than this number of CSI +/// params, but I suspect we'll introduce a slow path with heap allocation +/// one day. +const MAX_PARAMS = 24; /// Current state of the state machine state: State = .ground, @@ -689,6 +703,64 @@ test "csi: SGR mixed colon and semicolon with blank" { } } +// This is from a Kakoune actual SGR sequence also. +test "csi: SGR mixed colon and semicolon setting underline, bg, fg" { + var p = init(); + _ = p.next(0x1B); + for ("[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expectEqual(17, d.params.len); + try testing.expectEqual(@as(u16, 4), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); + try testing.expectEqual(@as(u16, 3), d.params[1]); + try testing.expect(!d.params_sep.isSet(1)); + try testing.expectEqual(@as(u16, 38), d.params[2]); + try testing.expect(!d.params_sep.isSet(2)); + try testing.expectEqual(@as(u16, 2), d.params[3]); + try testing.expect(!d.params_sep.isSet(3)); + try testing.expectEqual(@as(u16, 51), d.params[4]); + try testing.expect(!d.params_sep.isSet(4)); + try testing.expectEqual(@as(u16, 51), d.params[5]); + try testing.expect(!d.params_sep.isSet(5)); + try testing.expectEqual(@as(u16, 51), d.params[6]); + try testing.expect(!d.params_sep.isSet(6)); + try testing.expectEqual(@as(u16, 48), d.params[7]); + try testing.expect(!d.params_sep.isSet(7)); + try testing.expectEqual(@as(u16, 2), d.params[8]); + try testing.expect(!d.params_sep.isSet(8)); + try testing.expectEqual(@as(u16, 170), d.params[9]); + try testing.expect(!d.params_sep.isSet(9)); + try testing.expectEqual(@as(u16, 170), d.params[10]); + try testing.expect(!d.params_sep.isSet(10)); + try testing.expectEqual(@as(u16, 170), d.params[11]); + try testing.expect(!d.params_sep.isSet(11)); + try testing.expectEqual(@as(u16, 58), d.params[12]); + try testing.expect(!d.params_sep.isSet(12)); + try testing.expectEqual(@as(u16, 2), d.params[13]); + try testing.expect(!d.params_sep.isSet(13)); + try testing.expectEqual(@as(u16, 255), d.params[14]); + try testing.expect(!d.params_sep.isSet(14)); + try testing.expectEqual(@as(u16, 97), d.params[15]); + try testing.expect(!d.params_sep.isSet(15)); + try testing.expectEqual(@as(u16, 136), d.params[16]); + try testing.expect(!d.params_sep.isSet(16)); + } +} + test "csi: colon for non-m final" { var p = init(); _ = p.next(0x1B); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 52bfb2c31..2bc32c5f9 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -103,12 +103,16 @@ pub const Parser = struct { /// Next returns the next attribute or null if there are no more attributes. pub fn next(self: *Parser) ?Attribute { - if (self.idx > self.params.len) return null; + if (self.idx >= self.params.len) { + // If we're at index zero it means we must have an empty + // list and an empty list implicitly means unset. + if (self.idx == 0) { + // Add one to ensure we don't loop on unset + self.idx += 1; + return .unset; + } - // Implicitly means unset - if (self.params.len == 0) { - self.idx += 1; - return .unset; + return null; } const slice = self.params[self.idx..self.params.len]; @@ -788,7 +792,6 @@ test "sgr: direct fg colon with colorspace and extra param" { { const v = p.next().?; - std.log.warn("WHAT={}", .{v}); try testing.expect(v == .direct_color_fg); try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); @@ -864,3 +867,50 @@ test "sgr: kakoune input" { //try testing.expect(p.next() == null); } + +// Discussion #5930, another input sent by kakoune +test "sgr: kakoune input issue underline, fg, and bg" { + // echo -e "\033[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136mset everything in one sequence, broken\033[m" + + // This used to crash + var p: Parser = .{ + .params = &[_]u16{ 4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + break :sep list; + }, + }; + + { + const v = p.next().?; + try testing.expect(v == .underline); + try testing.expectEqual(Attribute.Underline.curly, v.underline); + } + + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 51), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 51), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 51), v.direct_color_fg.b); + } + + { + const v = p.next().?; + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 170), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 170), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 170), v.direct_color_bg.b); + } + + { + const v = p.next().?; + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 255), v.underline_color.r); + try testing.expectEqual(@as(u8, 97), v.underline_color.g); + try testing.expectEqual(@as(u8, 136), v.underline_color.b); + } + + try testing.expect(p.next() == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index eb5ab2c65..3d9ed72fb 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -932,7 +932,7 @@ pub fn Stream(comptime Handler: type) type { // SGR - Select Graphic Rendition 'm' => switch (input.intermediates.len) { 0 => if (@hasDecl(T, "setAttribute")) { - // log.info("parse SGR params={any}", .{action.params}); + // log.info("parse SGR params={any}", .{input.params}); var p: sgr.Parser = .{ .params = input.params, .params_sep = input.params_sep, From bc2acdd060bf6a10fbe1fa87a0035bd7fa68c260 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 23 Feb 2025 10:59:59 -0600 Subject: [PATCH 33/35] gtk: ensure that the content scale is always positive Fixes #5927 This doesn't fix the underlying reason that GTK sometimes reports content scales as negative. If GTK reports a negative scale, we ignore that and use 1.0 for the scale. --- src/apprt/gtk/Surface.zig | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 3c94989c2..e25375d98 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -847,28 +847,41 @@ pub fn shouldClose(self: *const Surface) bool { } pub fn getContentScale(self: *const Surface) !apprt.ContentScale { - // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we - // can support fractional scaling. - const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); + const gtk_scale: f32 = scale: { + const widget: *gtk.Widget = @ptrCast(@alignCast(self.gl_area)); + // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we + // can support fractional scaling. + const scale = widget.getScaleFactor(); + if (scale < 0) { + log.warn("gtk_widget_get_scale_factor returned a negative number: {d:.3}", .{scale}); + break :scale 1.0; + } + break :scale @floatFromInt(scale); + }; // Also scale using font-specific DPI, which is often exposed to the user // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). const xft_dpi_scale = xft_scale: { // gtk-xft-dpi is font DPI multiplied by 1024. See // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const settings = c.gtk_settings_get_default(); + const settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0; + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(c_int)); + settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); + const gtk_xft_dpi = value.getInt(); - 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(settings)), "gtk-xft-dpi", &value); - const gtk_xft_dpi = c.g_value_get_int(&value); + // ensure that the scale is never negative + if (gtk_xft_dpi < 0) { + log.warn("gtk-xft-dpi setting was negative: {d:.3}", .{gtk_xft_dpi}); + break :xft_scale 1.0; + } // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by // 1024, then divide by the default value (96) to derive a scale. Note // gtk-xft-dpi can be fractional, so we use floating point math here. - const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; - break :xft_scale xft_dpi / 96; + const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024.0; + break :xft_scale xft_dpi / 96.0; }; const scale = gtk_scale * xft_dpi_scale; From ac7029256abb21cfeed93abd3d7507fd55a6dd2a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 23 Feb 2025 13:04:47 -0600 Subject: [PATCH 34/35] gtk: better document what to do if gtk-xft-dpi <= 0 --- src/apprt/gtk/Surface.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index e25375d98..3e5fa45f5 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -871,9 +871,12 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); const gtk_xft_dpi = value.getInt(); - // ensure that the scale is never negative - if (gtk_xft_dpi < 0) { - log.warn("gtk-xft-dpi setting was negative: {d:.3}", .{gtk_xft_dpi}); + // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 + // See: + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead + if (gtk_xft_dpi <= 0) { + log.warn("gtk-xft-dpi was not set, using default value", .{}); break :xft_scale 1.0; } From da10269d3f6d7f3a23ed1e695ff644942f8796d3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 23 Feb 2025 13:18:00 -0600 Subject: [PATCH 35/35] gtk: handle other nonsensical values returned by gtk_widget_get_scale_factor --- src/apprt/gtk/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 3e5fa45f5..09d1c5a90 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -852,8 +852,8 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we // can support fractional scaling. const scale = widget.getScaleFactor(); - if (scale < 0) { - log.warn("gtk_widget_get_scale_factor returned a negative number: {d:.3}", .{scale}); + if (scale <= 0) { + log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); break :scale 1.0; } break :scale @floatFromInt(scale);