From 00cce91dc4d8382aa10fbc2cba83d1cff82e6384 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 23 Jul 2025 16:11:26 -0700 Subject: [PATCH] apprt/gtk-ng: surface progress bar --- src/apprt/gtk-ng/class/application.zig | 16 +++- src/apprt/gtk-ng/class/surface.zig | 112 +++++++++++++++++++++++++ src/apprt/gtk-ng/css/style.css | 9 ++ src/apprt/gtk-ng/ui/1.2/surface.blp | 15 ++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 2753391b0..f1bac0850 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -481,6 +481,8 @@ pub const Application = extern struct { .quit_timer => try Action.quitTimer(self, value), + .progress_report => return Action.progressReport(target, value), + .render => Action.render(self, target), .set_title => Action.setTitle(target, value), @@ -528,7 +530,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .progress_report, => { log.warn("unimplemented action={}", .{action}); return false; @@ -1117,6 +1118,19 @@ const Action = struct { } } + pub fn progressReport( + target: apprt.Target, + value: terminal.osc.Command.ProgressReport, + ) bool { + return switch (target) { + .app => false, + .surface => |v| surface: { + v.rt_surface.surface.setProgressReport(value); + break :surface true; + }, + }; + } + pub fn render(_: *Application, target: apprt.Target) void { switch (target) { .app => {}, diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 0e795fc4f..d2f85314a 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const adw = @import("adw"); const gdk = @import("gdk"); @@ -308,9 +309,13 @@ pub const Surface = extern struct { /// True when the child has exited. child_exited: bool = false, + // Progress bar + progress_bar_timer: ?c_uint = null, + // Template binds child_exited_overlay: *ChildExited, drop_target: *gtk.DropTarget, + progress_bar_overlay: *gtk.ProgressBar, pub var offset: c_int = 0; }; @@ -338,6 +343,93 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } + /// Set the current progress report state. + pub fn setProgressReport( + self: *Self, + value: terminal.osc.Command.ProgressReport, + ) void { + const priv = self.private(); + + // No matter what, we stop the timer because if we're removing + // then we're done and otherwise we restart it. + if (priv.progress_bar_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove progress bar timer", .{}); + } + priv.progress_bar_timer = null; + } + + const progress_bar = priv.progress_bar_overlay; + switch (value.state) { + // Remove the progress bar + .remove => { + progress_bar.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + }, + + // Set the progress bar to a fixed value if one was provided, otherwise pulse. + // Remove the `error` CSS class so that the progress bar shows as normal. + .set => { + progress_bar.as(gtk.Widget).removeCssClass("error"); + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } else { + progress_bar.pulse(); + } + }, + + // Set the progress bar to a fixed value if one was provided, otherwise pulse. + // Set the `error` CSS class so that the progress bar shows as an error color. + .@"error" => { + progress_bar.as(gtk.Widget).addCssClass("error"); + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } else { + progress_bar.pulse(); + } + }, + + // The state of progress is unknown, so pulse the progress bar to + // indicate that things are still happening. + .indeterminate => { + progress_bar.pulse(); + }, + + // If a progress value was provided, set the progress bar to that value. + // Don't pulse the progress bar as that would indicate that things were + // happening. Otherwise this is mainly used to keep the progress bar on + // screen instead of timing out. + .pause => { + if (value.progress) |progress| { + progress_bar.setFraction(computeFraction(progress)); + } + }, + } + + // Assume all states lead to visibility + assert(value.state != .remove); + progress_bar.as(gtk.Widget).setVisible(@intFromBool(true)); + + // Start our timer to remove bad actor programs that stall + // the progress bar. + const progress_bar_timeout_seconds = 15; + assert(priv.progress_bar_timer == null); + priv.progress_bar_timer = glib.timeoutAdd( + progress_bar_timeout_seconds * std.time.ms_per_s, + progressBarTimer, + self, + ); + } + + /// The progress bar hasn't been updated by the TUI recently, remove it. + fn progressBarTimer(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud.?)); + const priv = self.private(); + priv.progress_bar_timer = null; + self.setProgressReport(.{ .state = .remove }); + return @intFromBool(glib.SOURCE_REMOVE); + } + /// Key press event (press or release). /// /// At a high level, we want to construct an `input.KeyEvent` and @@ -873,6 +965,12 @@ pub const Surface = extern struct { v.unref(); priv.config = null; } + if (priv.progress_bar_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove progress bar timer", .{}); + } + priv.progress_bar_timer = null; + } gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -1833,6 +1931,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("url_left", .{}); class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("child_exited_overlay", .{}); + class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); @@ -2221,3 +2320,16 @@ fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool { } return false; } + +/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped +/// to [0, 100]. +fn computeFraction(progress: u8) f64 { + return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0; +} + +test "computeFraction" { + try std.testing.expectEqual(1.0, computeFraction(100)); + try std.testing.expectEqual(1.0, computeFraction(255)); + try std.testing.expectEqual(0.0, computeFraction(0)); + try std.testing.expectEqual(0.5, computeFraction(50)); +} diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index 3139155b7..314a96fae 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -84,3 +84,12 @@ label.url-overlay.right { /* after GTK 4.16 is a requirement, switch to the following: /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ } + +/* + * Surface + */ +.surface progressbar.error trough progress { + background-color: rgb(192, 28, 40); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */ +} diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 593841b60..6dda8164b 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -2,6 +2,10 @@ using Gtk 4.0; using Adw 1; template $GhosttySurface: Adw.Bin { + styles [ + "surface", + ] + notify::config => $notify_config(); notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); @@ -25,6 +29,17 @@ template $GhosttySurface: Adw.Bin { use-es: false; } + [overlay] + ProgressBar progress_bar_overlay { + styles [ + "osd", + ] + + visible: false; + halign: fill; + valign: start; + } + [overlay] $GhosttySurfaceChildExited child_exited_overlay { visible: bind template.child-exited;