From 6c40cd963d9ffe1fc10c4e5d8dea3d37b2364bbe Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 10 Jul 2024 11:58:03 -0500 Subject: [PATCH 1/6] gtk: implement unfocused-split opacity and fill For a long time, us GTK users have been subject to lesser UX by not knowing which split was focused. Improve the GTK UX by implementing both unfocused-split-opacity and unfocused-split-fill. This is implemented by setting the background-color of the notebook stack, and conditionally applying a new css class "unfocused-split" to the unfocused split. --- src/apprt/gtk/App.zig | 4 ++++ src/apprt/gtk/Surface.zig | 11 +++++++++++ src/apprt/gtk/Window.zig | 30 ++++++++++++++++++++++++++++++ src/config/Config.zig | 4 ---- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6d8bdce27..f0ba41d3a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -70,6 +70,10 @@ x11_xkb: ?x11.Xkb = null, /// and initialization was successful. transient_cgroup_base: ?[]const u8 = null, +/// True if we have initialized runtime CSS. We only need to do this once for the application, but +/// we can't perform the intialization until we have created a window +runtime_css_intialized: bool = false, + pub fn init(core_app: *CoreApp, opts: Options) !App { _ = opts; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 62a432c9e..d9b60bb39 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1809,6 +1809,9 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo // Notify our IM context c.gtk_im_context_focus_in(self.im_context); + // Unconditionally remove the unfocused split css class + c.gtk_widget_remove_css_class(@ptrCast(@alignCast(self.gl_area)), "unfocused-split"); + // Notify our surface self.core_surface.focusCallback(true) catch |err| { log.err("error in focus callback err={}", .{err}); @@ -1823,6 +1826,14 @@ fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo // Notify our IM context c.gtk_im_context_focus_out(self.im_context); + // We only add the unfocused-split class if we are actually a split + switch (self.container) { + .split_br, + .split_tl, + => c.gtk_widget_add_css_class(@ptrCast(@alignCast(self.gl_area)), "unfocused-split"), + else => {}, + } + self.core_surface.focusCallback(false) catch |err| { log.err("error in focus callback err={}", .{err}); return; diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0cb73c407..b68d0370e 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -16,6 +16,7 @@ const input = @import("../../input.zig"); const CoreSurface = @import("../../Surface.zig"); const App = @import("App.zig"); +const Color = configpkg.Config.Color; const Surface = @import("Surface.zig"); const Tab = @import("Tab.zig"); const c = @import("c.zig"); @@ -71,6 +72,35 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_set_opacity(@ptrCast(window), app.config.@"background-opacity"); } + if (!app.runtime_css_intialized) { + // Intialize runtime CSS. This CSS requires ghostty configuration values so we don't set it + // in style.css. We also have to have a window in order to add a style_context to a display, + // so we intialize after creation of our first window + app.runtime_css_intialized = true; + + const display = c.gdk_display_get_default(); + const provider = c.gtk_css_provider_new(); + defer c.g_object_unref(provider); + const fill: Color = app.config.@"unfocused-split-fill" orelse app.config.background; + var css_buf: [128]u8 = undefined; + + // We will add the unfocused-split class in our focus callbacks. We unconditionally add the + // background-color to the notebook stack because it only comes into play if we have an + // unfocused split + const css = try std.fmt.bufPrintZ( + &css_buf, + "widget.unfocused-split {{ opacity: {d:.2}; }}\nstack {{ background-color: rgb({d},{d},{d});}}", + .{ + app.config.@"unfocused-split-opacity", + fill.r, + fill.g, + fill.b, + }, + ); + c.gtk_css_provider_load_from_string(provider, css); + c.gtk_style_context_add_provider_for_display(display, @ptrCast(provider), c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + } + // Use the new GTK4 header bar. We only create a header bar if we have // window decorations. if (app.config.@"window-decoration") { diff --git a/src/config/Config.zig b/src/config/Config.zig index e3165a988..e4de1d22e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -409,8 +409,6 @@ palette: Palette = .{}, /// is 0.15. This value still looks weird but you can at least see what's going /// on. A value outside of the range 0.15 to 1 will be clamped to the nearest /// valid value. -/// -/// This is only supported on macOS. @"unfocused-split-opacity": f64 = 0.7, /// The color to dim the unfocused split. Unfocused splits are dimmed by @@ -418,8 +416,6 @@ palette: Palette = .{}, /// that rectangle and can be used to carefully control the dimming effect. /// /// This will default to the background color. -/// -/// This is only supported on macOS. @"unfocused-split-fill": ?Color = null, /// The command to run, usually a shell. If this is not an absolute path, it'll From d1d3de758c5b839d5a510199ee309734c77cec78 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 10 Jul 2024 13:14:34 -0500 Subject: [PATCH 2/6] gtk: prevent CSS collision on notebook stack Add a class to the GtkNotebook which holds our tabs so we can more precisely set the background color of just this `stack`. A collision was occurring with the menu widgets, which are also a `stack`. --- src/apprt/gtk/App.zig | 27 ++++++++++++++++++++++++--- src/apprt/gtk/Window.zig | 33 +++++---------------------------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index f0ba41d3a..51249af69 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -70,9 +70,8 @@ x11_xkb: ?x11.Xkb = null, /// and initialization was successful. transient_cgroup_base: ?[]const u8 = null, -/// True if we have initialized runtime CSS. We only need to do this once for the application, but -/// we can't perform the intialization until we have created a window -runtime_css_intialized: bool = false, +/// CSS Provider for any styles based on ghostty configuration values +css_provider: *c.GtkCssProvider, pub fn init(core_app: *CoreApp, opts: Options) !App { _ = opts; @@ -272,6 +271,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { ); } + const css_provider = c.gtk_css_provider_new(); + try loadRuntimeCss(&config, css_provider); + return .{ .core_app = core_app, .app = app, @@ -284,6 +286,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This means that another instance of the GTK app is running and // our "activate" call above will open a window. .running = c.g_application_get_is_remote(gapp) == 0, + .css_provider = css_provider, }; } @@ -331,6 +334,7 @@ pub fn reloadConfig(self: *App) !?*const Config { fn syncConfigChanges(self: *App) !void { try self.updateConfigErrors(); try self.syncActionAccelerators(); + try loadRuntimeCss(&self.config, self.css_provider); } /// This should be called whenever the configuration changes to update @@ -383,6 +387,23 @@ fn syncActionAccelerator( ); } +fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { + const fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; + var css_buf: [128]u8 = undefined; + const css = try std.fmt.bufPrintZ( + &css_buf, + "widget.unfocused-split {{ opacity: {d:.2}; }}\n.ghostty-surface>stack {{ background-color: rgb({d},{d},{d});}}", + .{ + config.@"unfocused-split-opacity", + fill.r, + fill.g, + fill.b, + }, + ); + // Clears any previously loaded CSS from this provider + c.gtk_css_provider_load_from_string(provider, css); +} + /// Called by CoreApp to wake up the event loop. pub fn wakeup(self: App) void { _ = self; diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index b68d0370e..a151fe92a 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -72,34 +72,10 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_set_opacity(@ptrCast(window), app.config.@"background-opacity"); } - if (!app.runtime_css_intialized) { - // Intialize runtime CSS. This CSS requires ghostty configuration values so we don't set it - // in style.css. We also have to have a window in order to add a style_context to a display, - // so we intialize after creation of our first window - app.runtime_css_intialized = true; - - const display = c.gdk_display_get_default(); - const provider = c.gtk_css_provider_new(); - defer c.g_object_unref(provider); - const fill: Color = app.config.@"unfocused-split-fill" orelse app.config.background; - var css_buf: [128]u8 = undefined; - - // We will add the unfocused-split class in our focus callbacks. We unconditionally add the - // background-color to the notebook stack because it only comes into play if we have an - // unfocused split - const css = try std.fmt.bufPrintZ( - &css_buf, - "widget.unfocused-split {{ opacity: {d:.2}; }}\nstack {{ background-color: rgb({d},{d},{d});}}", - .{ - app.config.@"unfocused-split-opacity", - fill.r, - fill.g, - fill.b, - }, - ); - c.gtk_css_provider_load_from_string(provider, css); - c.gtk_style_context_add_provider_for_display(display, @ptrCast(provider), c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); - } + // Internally, GTK ensures that only one instance of this provider exists in the provider list + // for the display. + const display = c.gdk_display_get_default(); + c.gtk_style_context_add_provider_for_display(display, @ptrCast(app.css_provider), c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); // Use the new GTK4 header bar. We only create a header bar if we have // window decorations. @@ -131,6 +107,7 @@ pub fn init(self: *Window, app: *App) !void { // Create a notebook to hold our tabs. const notebook_widget = c.gtk_notebook_new(); + c.gtk_widget_add_css_class(notebook_widget, "ghostty-surface"); const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); self.notebook = notebook; const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { From f04fe01ac66b096be293f1390d8455522c57c948 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 10 Jul 2024 14:17:58 -0500 Subject: [PATCH 3/6] gtk: improve readability of runtime css fmt --- src/apprt/gtk/App.zig | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 51249af69..8210a448d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -389,10 +389,22 @@ fn syncActionAccelerator( fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { const fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; - var css_buf: [128]u8 = undefined; + const fmt = + \\widget.unfocused-split {{ + \\ opacity: {d:.2}; + \\}} + \\.ghostty-surface>stack {{ + \\ background-color: rgb({d},{d},{d}); + \\}}" + ; + // The length required is always less than the length of the pre-formatted string: + // -> '{d:.2}' gets replaced with max 4 bytes (0.00) + // -> each {d} could be replaced with max 3 bytes + var css_buf: [fmt.len]u8 = undefined; + const css = try std.fmt.bufPrintZ( &css_buf, - "widget.unfocused-split {{ opacity: {d:.2}; }}\n.ghostty-surface>stack {{ background-color: rgb({d},{d},{d});}}", + fmt, .{ config.@"unfocused-split-opacity", fill.r, From 11c2ae10072a23848a47d84d61ba45e401d247cb Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 11 Jul 2024 08:28:47 -0500 Subject: [PATCH 4/6] gtk: use GtkDrawingArea to dim unfocused split Refactor the GTK unfocused split code to use a GtkDrawingArea widget to dim the unfocused split. The GtkDrawingArea is added to the overlay and a CSS style is used to give it a background color and opacity. This aligns with the macOS design of drawing on top of the surface. In GTK, we don't need to actually draw a rectangle because we can apply CSS directly to the widget. --- src/apprt/gtk/App.zig | 6 ++---- src/apprt/gtk/Surface.zig | 23 +++++++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 8210a448d..80749437f 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -392,10 +392,8 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { const fmt = \\widget.unfocused-split {{ \\ opacity: {d:.2}; - \\}} - \\.ghostty-surface>stack {{ \\ background-color: rgb({d},{d},{d}); - \\}}" + \\}} ; // The length required is always less than the length of the pre-formatted string: // -> '{d:.2}' gets replaced with max 4 bytes (0.00) @@ -413,7 +411,7 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { }, ); // Clears any previously loaded CSS from this provider - c.gtk_css_provider_load_from_string(provider, css); + c.gtk_css_provider_load_from_data(provider, css, @intCast(css.len)); } /// Called by CoreApp to wake up the event loop. diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index d9b60bb39..f370a05a8 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -325,6 +325,9 @@ gl_area: *c.GtkGLArea, /// If non-null this is the widget on the overlay that shows the URL. url_widget: ?URLWidget = null, +/// If non-null this is the widget on the overlay which dims the surface when it is unfocused +unfocused_widget: ?*c.GtkWidget = null, + /// Any active cursor we may have cursor: ?*c.GdkCursor = null, @@ -590,6 +593,10 @@ pub fn deinit(self: *Surface) void { // Free all our GTK stuff c.g_object_unref(self.im_context); if (self.cursor) |cursor| c.g_object_unref(cursor); + if (self.unfocused_widget) |widget| { + c.gtk_overlay_remove_overlay(self.overlay, widget); + self.unfocused_widget = null; + } } // unref removes the long-held reference to the gl_area and kicks off the @@ -1809,8 +1816,11 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo // Notify our IM context c.gtk_im_context_focus_in(self.im_context); - // Unconditionally remove the unfocused split css class - c.gtk_widget_remove_css_class(@ptrCast(@alignCast(self.gl_area)), "unfocused-split"); + // Remove the unfocused widget overlay, if we have one + if (self.unfocused_widget) |widget| { + c.gtk_overlay_remove_overlay(self.overlay, widget); + self.unfocused_widget = null; + } // Notify our surface self.core_surface.focusCallback(true) catch |err| { @@ -1826,11 +1836,16 @@ fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo // Notify our IM context c.gtk_im_context_focus_out(self.im_context); - // We only add the unfocused-split class if we are actually a split + // We only add the unfocused-split widget if we are actually a split switch (self.container) { .split_br, .split_tl, - => c.gtk_widget_add_css_class(@ptrCast(@alignCast(self.gl_area)), "unfocused-split"), + => blk: { + if (self.unfocused_widget != null) break :blk; + self.unfocused_widget = c.gtk_drawing_area_new(); + c.gtk_widget_add_css_class(self.unfocused_widget.?, "unfocused-split"); + c.gtk_overlay_add_overlay(self.overlay, self.unfocused_widget.?); + }, else => {}, } From fc3d885022bfa004cefd2dab72215508204b86d0 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 11 Jul 2024 08:34:51 -0500 Subject: [PATCH 5/6] gtk: remove unused css class on GtkNotebook --- src/apprt/gtk/Window.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index a151fe92a..7a3418bb4 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -107,7 +107,6 @@ pub fn init(self: *Window, app: *App) !void { // Create a notebook to hold our tabs. const notebook_widget = c.gtk_notebook_new(); - c.gtk_widget_add_css_class(notebook_widget, "ghostty-surface"); const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); self.notebook = notebook; const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { From 76df7321690920ff7ee48d6721def6100e2b2042 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 11 Jul 2024 10:29:40 -0500 Subject: [PATCH 6/6] gtk: add unfocused_widget when split created from menu When a split is created from a menu action, the focus is lost before the split is made which prevents the surface from having the unfocused_widget. Move the logic to add the unfocused_widget to the overlay to an exported function which is called when the split is created. --- src/apprt/gtk/Split.zig | 1 + src/apprt/gtk/Surface.zig | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 148651d4b..622db61fe 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -77,6 +77,7 @@ pub fn init( .parent = &sibling.core_surface, }); errdefer surface.destroy(alloc); + sibling.dimSurface(); // Create the actual GTKPaned, attach the proper children. const orientation: c_uint = switch (direction) { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f370a05a8..05fce6905 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1836,16 +1836,11 @@ fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo // Notify our IM context c.gtk_im_context_focus_out(self.im_context); - // We only add the unfocused-split widget if we are actually a split + // We only dim the surface if we are a split switch (self.container) { .split_br, .split_tl, - => blk: { - if (self.unfocused_widget != null) break :blk; - self.unfocused_widget = c.gtk_drawing_area_new(); - c.gtk_widget_add_css_class(self.unfocused_widget.?, "unfocused-split"); - c.gtk_overlay_add_overlay(self.overlay, self.unfocused_widget.?); - }, + => self.dimSurface(), else => {}, } @@ -1855,6 +1850,15 @@ 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 { + if (self.unfocused_widget != null) return; + self.unfocused_widget = c.gtk_drawing_area_new(); + c.gtk_widget_add_css_class(self.unfocused_widget.?, "unfocused-split"); + c.gtk_overlay_add_overlay(self.overlay, self.unfocused_widget.?); +} + fn gtkCloseConfirmation( alert: *c.GtkMessageDialog, response: c.gint,