From 301826dffff3938c2fbeb89bfbb9e8fd96f56886 Mon Sep 17 00:00:00 2001
From: "Jeffrey C. Ollie" <jeff@ocjtech.us>
Date: Fri, 9 Aug 2024 22:58:33 -0500
Subject: [PATCH] gtk: add resize overlay

This adds a transient overlay that shows the size of the surface
while you are resizing the window or the surfaces.
---
 src/apprt/gtk/App.zig     |   8 +-
 src/apprt/gtk/Surface.zig | 157 ++++++++++++++++++++++++++++++++++++++
 src/apprt/gtk/style.css   |  12 +++
 src/config/Config.zig     |  97 +++++++++++++++++++++++
 4 files changed, 267 insertions(+), 7 deletions(-)

diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig
index 60980ba1d6..eb7ce82c46 100644
--- a/src/apprt/gtk/App.zig
+++ b/src/apprt/gtk/App.zig
@@ -531,13 +531,7 @@ pub fn startQuitTimer(self: *App) void {
 
     if (self.config.@"quit-after-last-window-closed-delay") |v| {
         // If a delay is configured, set a timeout function to quit after the delay.
-        const ms: u64 = std.math.divTrunc(
-            u64,
-            v.duration,
-            std.time.ns_per_ms,
-        ) catch std.math.maxInt(c.guint);
-        const t = std.math.cast(c.guint, ms) orelse std.math.maxInt(c.guint);
-        self.quit_timer = .{ .active = c.g_timeout_add(t, gtkQuitTimerExpired, self) };
+        self.quit_timer = .{ .active = c.g_timeout_add(v.asMilliseconds(), gtkQuitTimerExpired, self) };
     } else {
         // If no delay is configured, treat it as expired.
         self.quit_timer = .{ .expired = {} };
diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig
index 2a7c7d1838..976820874a 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -325,6 +325,19 @@ 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 that shows the size of the
+/// surface when it is resized.
+resize_overlay_widget: ?*c.GtkWidget = null,
+
+/// If non-null this is a timer for dismissing the resize overlay.
+resize_overlay_timer: ?c.guint = null,
+
+/// If non-null this is a timer for dismissing the resize overlay.
+resize_overlay_idler: ?c.guint = null,
+
+/// If true, the next resize event will be the first one.
+resize_overlay_first: bool = true,
+
 /// If non-null this is the widget on the overlay which dims the surface when it is unfocused
 unfocused_widget: ?*c.GtkWidget = null,
 
@@ -456,6 +469,12 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
         break :font_size parent.font_size;
     };
 
+    const resize_overlay_widget = maybeCreateResizeOverlay(&app.config);
+
+    if (resize_overlay_widget) |widget| {
+        c.gtk_overlay_add_overlay(@ptrCast(overlay), @ptrCast(widget));
+    }
+
     // If the parent has a transient cgroup, then we're creating cgroups
     // for each surface if we can. We need to create a child cgroup.
     const cgroup_path: ?[]const u8 = cgroup: {
@@ -500,6 +519,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
         .cursor_pos = .{ .x = 0, .y = 0 },
         .im_context = im_context,
         .cgroup_path = cgroup_path,
+        .resize_overlay_widget = resize_overlay_widget,
     };
     errdefer self.* = undefined;
 
@@ -597,6 +617,24 @@ pub fn deinit(self: *Surface) void {
         c.gtk_overlay_remove_overlay(self.overlay, widget);
         self.unfocused_widget = null;
     }
+    if (self.resize_overlay_idler) |idler| {
+        if (c.g_source_remove(idler) == c.FALSE) {
+            log.warn("unable to remove resize overlay idler", .{});
+        }
+    }
+    if (self.resize_overlay_timer) |timer| {
+        if (c.g_source_remove(timer) == c.FALSE) {
+            log.warn("unable to remove resize overlay timer", .{});
+        }
+    }
+
+    // This probably _should_ be uncommented, but I get segfaults if I do, and
+    // none if I dont. ¯\_(ツ)_/¯
+
+    // if (self.resize_overlay_widget) |widget| {
+    //     c.gtk_overlay_remove_overlay(self.overlay, widget);
+    //     self.resize_overlay_widget = null;
+    // }
 }
 
 // unref removes the long-held reference to the gl_area and kicks off the
@@ -1299,6 +1337,8 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
             log.err("error in size callback err={}", .{err});
             return;
         };
+
+        self.maybeShowResizeOverlay();
     }
 }
 
@@ -1974,3 +2014,120 @@ fn translateMods(state: c.GdkModifierType) input.Mods {
     if (state & c.GDK_LOCK_MASK != 0) mods.caps_lock = true;
     return mods;
 }
+
+/// If we're configured to do so, create a label widget for displaying the size
+/// of the surface during a resize event.
+fn maybeCreateResizeOverlay(config: *const configpkg.Config) ?*c.GtkWidget {
+    if (config.@"resize-overlay" == .never) return null;
+
+    const widget = c.gtk_label_new("");
+
+    c.gtk_widget_add_css_class(@ptrCast(widget), "view");
+    c.gtk_widget_add_css_class(@ptrCast(widget), "size-overlay");
+    c.gtk_widget_add_css_class(@ptrCast(widget), "hidden");
+    c.gtk_widget_set_visible(@ptrCast(widget), c.FALSE);
+    c.gtk_widget_set_focusable(@ptrCast(widget), c.FALSE);
+    c.gtk_widget_set_can_target(@ptrCast(widget), c.FALSE);
+    c.gtk_label_set_justify(@ptrCast(widget), c.GTK_JUSTIFY_CENTER);
+    c.gtk_label_set_selectable(@ptrCast(widget), c.FALSE);
+
+    setOverlayWidgetPosition(widget, config);
+
+    return widget;
+}
+
+/// If we're configured to do so, update the text in the resize overlay widget
+/// and make it visible. Schedule a timer to hide the widget after the delay
+/// expires.
+fn maybeShowResizeOverlay(self: *Surface) void {
+    if (self.app.config.@"resize-overlay" == .never) return;
+
+    if (self.app.config.@"resize-overlay" == .@"after-first" and self.resize_overlay_first) {
+        self.resize_overlay_first = false;
+        return;
+    }
+
+    self.resize_overlay_first = false;
+
+    // When updating a widget, do so from GTK's thread, but not if there's
+    // already an update queued up.
+    if (self.resize_overlay_idler != null) return;
+    self.resize_overlay_idler = c.g_idle_add(gtkUpdateOverlayWidget, @ptrCast(self));
+}
+
+/// Actually update the overlay widget. This should only be called as an idle
+/// handler.
+fn gtkUpdateOverlayWidget(ud: ?*anyopaque) callconv(.C) c.gboolean {
+    const self: *Surface = @ptrCast(@alignCast(ud));
+
+    if (self.resize_overlay_widget) |widget| {
+        var buf: [64]u8 = undefined;
+        const text = std.fmt.bufPrintZ(
+            &buf,
+            "{d}c ⨯ {d}r\n{d}px ⨯ {d}px",
+            .{
+                self.core_surface.grid_size.columns,
+                self.core_surface.grid_size.rows,
+                self.core_surface.screen_size.width,
+                self.core_surface.screen_size.height,
+            },
+        ) catch |err| {
+            log.err("unable to format text: {}", .{err});
+            return c.FALSE;
+        };
+
+        c.gtk_label_set_text(@ptrCast(widget), text.ptr);
+        c.gtk_widget_remove_css_class(@ptrCast(widget), "hidden");
+        c.gtk_widget_set_visible(@ptrCast(widget), 1);
+
+        setOverlayWidgetPosition(widget, &self.app.config);
+
+        if (self.resize_overlay_timer) |timer| {
+            if (c.g_source_remove(timer) == c.FALSE) {
+                log.warn("unable to remove size overlay timer", .{});
+            }
+        }
+        self.resize_overlay_timer = c.g_timeout_add(
+            self.app.config.@"resize-overlay-delay".asMilliseconds(),
+            gtkResizeOverlayTimerExpired,
+            @ptrCast(self),
+        );
+    }
+
+    self.resize_overlay_idler = null;
+
+    return c.FALSE;
+}
+
+/// 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.
+fn setOverlayWidgetPosition(widget: *c.GtkWidget, config: *const 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,
+        },
+    );
+    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,
+        },
+    );
+}
+
+/// If this fires, it means that the delay period has expired and the resize
+/// overlay widget should be hidden.
+fn gtkResizeOverlayTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
+    const self: *Surface = @ptrCast(@alignCast(ud));
+    if (self.resize_overlay_widget) |widget| {
+        c.gtk_widget_add_css_class(@ptrCast(widget), "hidden");
+        c.gtk_widget_set_visible(@ptrCast(widget), c.FALSE);
+        self.resize_overlay_timer = null;
+    }
+    return c.FALSE;
+}
diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css
index 4f52015f49..d9ac9abc3b 100644
--- a/src/apprt/gtk/style.css
+++ b/src/apprt/gtk/style.css
@@ -9,3 +9,15 @@ label.url-overlay:hover {
 label.url-overlay.hidden {
   opacity: 0;
 }
+
+label.size-overlay {
+  padding: 4px 8px 4px 8px;
+  border-radius: 6px 6px 6px 6px;
+  outline-style: solid;
+  outline-width: 1px;
+  outline-color: #555555;
+}
+
+label.size-overlay.hidden {
+  opacity: 0;
+}
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 379f47bd11..b4afd53da2 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -858,6 +858,74 @@ keybind: Keybinds = .{},
 /// This configuration currently only works with GTK.
 @"window-new-tab-position": WindowNewTabPosition = .current,
 
+/// This controls when resize overlays are shown. Resize overlays are a
+/// transient popup that shows the size of the terminal while the surfaces are
+/// being resized. The possible options are:
+///
+///   * `always` - Always show resize overlays.
+///   * `never` - Never show resize overlays.
+///   * `after-first` - The resize overlay will not appear when the surface
+///                     is first created, but will show up if the surface is
+///                     subsequently resized.
+///
+/// The default is `always`.
+///
+/// Changing this value at runtime and reloading the configuration will only
+/// affect new windows, tabs, and splits.
+///
+/// Linux/GTK only.
+@"resize-overlay": ResizeOverlay = .always,
+
+/// If resize overlays are enabled, this controls the position of the overlay.
+/// The possible options are:
+///
+///   * `center`
+///   * `top-left`
+///   * `top-center`
+///   * `top-right`
+///   * `bottom-left`
+///   * `bottom-center`
+///   * `bottom-right`
+///
+/// The default is `center`.
+///
+/// Linux/GTK only.
+@"resize-overlay-position": ResizeOverlayPosition = .center,
+
+/// If resize overlays are enabled, this controls how long the overlay is
+/// visible on the screen before it is hidden. The default is ¾ of a second or
+/// 750 ms.
+///
+/// The duration is specified as a series of numbers followed by time units.
+/// Whitespace is allowed between numbers and units. Each number and unit will
+/// be added together to form the total duration.
+///
+/// The allowed time units are as follows:
+///
+///   * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments
+///     are made for leap years or leap seconds.
+///   * `d` - one SI day, or 86400 seconds.
+///   * `h` - one hour, or 3600 seconds.
+///   * `m` - one minute, or 60 seconds.
+///   * `s` - one second.
+///   * `ms` - one millisecond, or 0.001 second.
+///   * `us` or `µs` - one microsecond, or 0.000001 second.
+///   * `ns` - one nanosecond, or 0.000000001 second.
+///
+/// Examples:
+///   * `1h30m`
+///   * `45s`
+///
+/// Units can be repeated and will be added together. This means that
+/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided.
+/// A future update may disallow this.
+///
+/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any
+/// value larger than this will be clamped to the maximum value.
+///
+/// Linux/GTK only.
+@"resize-overlay-delay": Duration = .{ .duration = 750 * std.time.ns_per_ms },
+
 // If true, when there are multiple split panes, the mouse selects the pane
 // that is focused. This only applies to the currently focused window; i.e.
 // mousing over a split in an unfocused window will now focus that split
@@ -3901,6 +3969,24 @@ pub const WindowNewTabPosition = enum {
     end,
 };
 
+/// See resize-overlay
+pub const ResizeOverlay = enum {
+    always,
+    never,
+    @"after-first",
+};
+
+/// See resize-overlay-position
+pub const ResizeOverlayPosition = enum {
+    center,
+    @"top-left",
+    @"top-center",
+    @"top-right",
+    @"bottom-left",
+    @"bottom-center",
+    @"bottom-right",
+};
+
 /// See grapheme-width-method
 pub const GraphemeWidthMethod = enum {
     legacy,
@@ -4030,6 +4116,17 @@ pub const Duration = struct {
             }
         }
     }
+
+    /// Convenience function to convert to milliseconds since many OS and
+    /// library timing functions operate on that timescale.
+    pub fn asMilliseconds(self: @This()) c_uint {
+        const ms: u64 = std.math.divTrunc(
+            u64,
+            self.duration,
+            std.time.ns_per_ms,
+        ) catch std.math.maxInt(c_uint);
+        return std.math.cast(c_uint, ms) orelse std.math.maxInt(c_uint);
+    }
 };
 
 pub const WindowPadding = struct {