diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9ef2f5cc4..49d452ef9 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -70,8 +70,10 @@ parts: plugin: nil build-attributes: [enable-patchelf] build-packages: + - blueprint-compiler - libgtk-4-dev - libadwaita-1-dev + - libxml2-utils - git - patchelf override-build: | diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index d8fcaa74c..2859bd6b3 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -58,12 +58,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, @@ -448,8 +442,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| { @@ -478,7 +470,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), @@ -504,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, @@ -515,7 +507,6 @@ pub fn performAction( .render_inspector, .renderer_health, .color_change, - .prompt_title, => { log.warn("unimplemented action={}", .{action}); return false; @@ -779,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, @@ -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), } } @@ -914,6 +914,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}); @@ -1012,17 +1015,20 @@ 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 = {} }); + try self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); } fn syncActionAccelerator( @@ -1254,10 +1260,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 @@ -1775,87 +1779,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..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 => { @@ -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/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 9a8c4513d..09d1c5a90 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 gobject = @import("gobject"); const Allocator = std.mem.Allocator; const build_config = @import("../../build_config.zig"); const build_options = @import("build_options"); @@ -20,11 +24,14 @@ 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"); 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); @@ -266,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, @@ -276,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 { @@ -329,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, @@ -346,6 +353,12 @@ 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 overridden by the user, this will be set to a non-null value +/// representing the default terminal title. +title_from_terminal: ?[:0]const u8 = null, + /// Our current working directory. We use this value for setting tooltips in /// the headerbar subtitle if we have focus. When set, the text in this buf /// will be null-terminated because we need to pass it to GTK. @@ -378,6 +391,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 { @@ -567,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, @@ -576,9 +592,17 @@ 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))); + + // initialize the resize overlay + self.resize_overlay.init(self, &app.config); + // Set our default mouse shape try self.setMouseShape(.text); @@ -654,6 +678,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. @@ -682,6 +707,11 @@ 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.resize_overlay.updateConfig(config); +} + // unref removes the long-held reference to the gl_area and kicks off the // deinit/destroy process for this surface. pub fn unref(self: *Surface) void { @@ -817,28 +847,44 @@ 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 non-positive number: {}", .{scale}); + break :scale 1.0; + } + break :scale @floatFromInt(scale); + }; // Also scale using font-specific DPI, which is often exposed to the user // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). const xft_dpi_scale = xft_scale: { // gtk-xft-dpi is font DPI multiplied by 1024. See // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const settings = 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); + // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 + // See: + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 + // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead + if (gtk_xft_dpi <= 0) { + log.warn("gtk-xft-dpi was not set, using default value", .{}); + break :xft_scale 1.0; + } // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by // 1024, then divide by the default value (96) to derive a scale. Note // gtk-xft-dpi can be fractional, so we use floating point math here. - const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; - 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; @@ -913,7 +959,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. @@ -931,8 +977,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 @@ -945,6 +992,14 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { }; errdefer alloc.free(copy); + // The user has overridden the title + // We only want to update the terminal provided title so that it can be restored to the most recent state. + if (self.title_from_terminal != null and source == .terminal) { + alloc.free(self.title_from_terminal.?); + self.title_from_terminal = copy; + return; + } + if (self.title_text) |old| alloc.free(old); self.title_text = copy; @@ -969,15 +1024,41 @@ 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..]; +} + +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 = builder.getObject(gtk.Entry, "title_entry").?; + entry.getBuffer().setText(self.getTitle() orelse "", -1); + + const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?; + dialog.choose(@ptrCast(window.window), null, gtkPromptTitleResponse, self); +} + /// Set the current working directory of the surface. /// /// In addition, update the tab's tooltip text, and if we are the focused child, @@ -1224,6 +1305,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 +1343,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 +1513,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 +2121,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(); @@ -2298,3 +2345,40 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { } return false; } + +fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + 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 = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; + const title = std.mem.span(title_entry.getBuffer().getText()); + + // if the new title is empty and the user has set the title previously, restore the terminal provided title + if (title.len == 0) { + if (self.getTerminalTitle()) |terminal_title| { + self.setTitle(terminal_title, .user) catch |err| { + log.err("failed to set title={}", .{err}); + }; + self.app.core_app.alloc.free(self.title_from_terminal.?); + self.title_from_terminal = null; + } + } else if (title.len > 0) { + // if this is the first time the user is setting the title, save the current terminal provided title + if (self.title_from_terminal == null and self.title_text != null) { + self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { + error.OutOfMemory => { + log.err("failed to allocate memory for title={}", .{err}); + return; + }, + }; + } + + self.setTitle(title, .user) catch |err| { + log.err("failed to set title={}", .{err}); + }; + } + } +} 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..5d1efd3ca 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"); @@ -31,6 +32,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 @@ -46,7 +51,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, @@ -107,12 +113,13 @@ 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, .tab_overview = null, .notebook = undefined, - .context_menu = undefined, + .titlebar_menu = undefined, .toast_overlay = undefined, .winproto = .none, }; @@ -137,6 +144,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 +184,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 +277,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 +285,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); @@ -355,6 +367,13 @@ pub fn updateConfig( self: *Window, config: *const configpkg.Config, ) !void { + // avoid multiple reconfigs when we have many surfaces contained in this + // 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; + self.last_config = this_config; + self.config = DerivedConfig.init(config); // We always resync our appearance whenever the config changes. @@ -459,16 +478,19 @@ 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 }, + .{ "prompt-title", >kActionPromptTitle }, }; inline for (actions) |entry| { @@ -487,8 +509,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 +772,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 +929,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 +954,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 +1071,55 @@ 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; + }; +} + +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. -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 { + // 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) { + self.titlebar_menu.refresh(); + } else { + self.focusCurrentTab(); + } +} + fn userdataSelf(ud: *anyopaque) *Window { return @ptrCast(@alignCast(ud)); } 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 050605b00..4bd08ed0a 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -54,7 +54,19 @@ const icons = [_]struct { }; pub const ui_files = [_][]const u8{}; -pub const blueprint_files = [_][]const u8{}; + +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" }, + .{ .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 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -69,9 +81,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)); } } @@ -141,7 +153,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/menu.zig b/src/apprt/gtk/menu.zig new file mode 100644 index 000000000..ef70df1b7 --- /dev/null +++ b/src/apprt/gtk/menu.zig @@ -0,0 +1,136 @@ +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, .blp); + 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.Widget).setHalign(.start); + 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/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; } 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-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/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp new file mode 100644 index 000000000..ffe38c980 --- /dev/null +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +Adw.AlertDialog prompt_title_dialog { + heading: _("Change Terminal Title"); + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel") suggested, + ok: _("OK") destructive + ] + + focus-widget: title_entry; + + extra-child: Entry 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 61e9e75c1..7f60ddf1d 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -5,11 +5,14 @@ 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 \ pandoc \ wget \ # Ghostty Dependencies diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 86a8a8098..7ace64cd8 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -4,6 +4,11 @@ _\$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. This location takes +precedence over the XDG environment locations. + _\$LOCALAPPDATA/ghostty/config_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched @@ -23,6 +28,11 @@ for configuration files. : Default location for configuration files. +**$HOME/Library/Application Support/com.mitchellh.ghostty** + +: **MACOS ONLY** default location for configuration files. This location takes +precedence over the XDG environment locations. + **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..c5077ab97 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -4,6 +4,11 @@ _\$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. This location takes +precedence over the XDG environment locations. + _\$LOCALAPPDATA/ghostty/config_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched @@ -15,6 +20,11 @@ for configuration files. : Default location for configuration files. +**$HOME/Library/Application Support/com.mitchellh.ghostty** + +: **MACOS ONLY** default location for configuration files. This location takes +precedence over the XDG environment locations. + **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. diff --git a/src/config/Config.zig b/src/config/Config.zig index 768629fd4..5a47cb1b3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4354,7 +4354,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()); } @@ -4423,6 +4425,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, " ")); 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. 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,