diff --git a/include/ghostty.h b/include/ghostty.h index fcad7af30..fb4c850dc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -686,6 +686,23 @@ typedef struct { uint64_t timetime_ms; } ghostty_surface_message_childexited_s; +// terminal.osc.Command.ProgressReport.State +typedef enum { + GHOSTTY_PROGRESS_STATE_REMOVE, + GHOSTTY_PROGRESS_STATE_SET, + GHOSTTY_PROGRESS_STATE_ERROR, + GHOSTTY_PROGRESS_STATE_INDETERMINATE, + GHOSTTY_PROGRESS_STATE_PAUSE, +} ghostty_terminal_osc_command_progressreport_state_e; + +// terminal.osc.Command.ProgressReport.C +typedef struct { + ghostty_terminal_osc_command_progressreport_state_e state; + // -1 if no progress was reported, otherwise 0-100 indicating percent + // completeness. + int8_t progress; +} ghostty_terminal_osc_command_progressreport_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, @@ -737,7 +754,8 @@ typedef enum { GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, GHOSTTY_ACTION_OPEN_URL, - GHOSTTY_ACTION_SHOW_CHILD_EXITED + GHOSTTY_ACTION_SHOW_CHILD_EXITED, + GHOSTTY_ACTION_PROGRESS_REPORT, } ghostty_action_tag_e; typedef union { @@ -767,6 +785,7 @@ typedef union { ghostty_action_config_change_s config_change; ghostty_action_open_url_s open_url; ghostty_surface_message_childexited_s child_exited; + ghostty_terminal_osc_command_progressreport_s progress_report; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index a73046500..b12750545 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -951,6 +951,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }; }, + .progress_report => |v| { + _ = self.rt_app.performAction( + .{ .surface = self }, + .progress_report, + v, + ) catch |err| { + log.warn("apprt failed to report progress err={}", .{err}); + }; + }, + .selection_scroll_tick => |active| { self.selection_scroll_active = active; try self.selectionScrollTick(); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 201d27e31..1afd59869 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -275,6 +275,9 @@ pub const Action = union(Key) { /// Show a native GUI notification that the child process has exited. show_child_exited: apprt.surface.Message.ChildExited, + /// Show a native GUI notification about the progress of some TUI operation. + progress_report: terminal.osc.Command.ProgressReport, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -327,6 +330,7 @@ pub const Action = union(Key) { check_for_updates, open_url, show_child_exited, + progress_report, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index fc6f574d5..4ea85a649 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -381,6 +381,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .progress_report, => { log.warn("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 314998285..99120992e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -523,6 +523,7 @@ pub fn performAction( .toggle_command_palette => try self.toggleCommandPalette(target), .open_url => self.openUrl(value), .show_child_exited => return try self.showChildExited(target, value), + .progress_report => return try self.handleProgressReport(target, value), // Unimplemented .close_all_windows, @@ -872,6 +873,14 @@ fn showChildExited(_: *App, target: apprt.Target, value: apprt.surface.Message.C } } +/// Show a native GUI element to indicate the progress of a TUI operation. +fn handleProgressReport(_: *App, target: apprt.Target, value: terminal.osc.Command.ProgressReport) error{}!bool { + switch (target) { + .app => return false, + .surface => |surface| return try surface.rt_surface.progress_bar.handleProgressReport(value), + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), diff --git a/src/apprt/gtk/ProgressBar.zig b/src/apprt/gtk/ProgressBar.zig new file mode 100644 index 000000000..1518e84c2 --- /dev/null +++ b/src/apprt/gtk/ProgressBar.zig @@ -0,0 +1,165 @@ +//! Structure for managing GUI progress bar for a surface. +const ProgressBar = @This(); + +const std = @import("std"); + +const glib = @import("glib"); +const gtk = @import("gtk"); + +const Surface = @import("./Surface.zig"); +const terminal = @import("../../terminal/main.zig"); + +const log = std.log.scoped(.gtk_progress_bar); + +/// The surface that we belong to. +surface: *Surface, + +/// Widget for showing progress bar. +progress_bar: ?*gtk.ProgressBar = null, + +/// Timer used to remove the progress bar if we have not received an update from +/// the TUI in a while. +progress_bar_timer: ?c_uint = null, + +pub fn init(surface: *Surface) ProgressBar { + return .{ + .surface = surface, + }; +} + +pub fn deinit(self: *ProgressBar) void { + self.stopProgressBarTimer(); +} + +/// Show (or update if it already exists) a GUI progress bar. +pub fn handleProgressReport(self: *ProgressBar, value: terminal.osc.Command.ProgressReport) error{}!bool { + // Remove the progress bar. + if (value.state == .remove) { + self.stopProgressBarTimer(); + self.removeProgressBar(); + + return true; + } + + const progress_bar = self.addProgressBar(); + self.startProgressBarTimer(); + + switch (value.state) { + // already handled above + .remove => unreachable, + + // 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)); + } + }, + } + + return true; +} + +/// 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)); +} + +/// Add a progress bar to our overlay. +fn addProgressBar(self: *ProgressBar) *gtk.ProgressBar { + if (self.progress_bar) |progress_bar| return progress_bar; + + const progress_bar = gtk.ProgressBar.new(); + self.progress_bar = progress_bar; + + const progress_bar_widget = progress_bar.as(gtk.Widget); + progress_bar_widget.setHalign(.fill); + progress_bar_widget.setValign(.start); + progress_bar_widget.addCssClass("osd"); + + self.surface.overlay.addOverlay(progress_bar_widget); + + return progress_bar; +} + +/// Remove the progress bar from our overlay. +fn removeProgressBar(self: *ProgressBar) void { + if (self.progress_bar) |progress_bar| { + const progress_bar_widget = progress_bar.as(gtk.Widget); + self.surface.overlay.removeOverlay(progress_bar_widget); + self.progress_bar = null; + } +} + +/// Start a timer that will remove the progress bar if the TUI forgets to remove +/// it. +fn startProgressBarTimer(self: *ProgressBar) void { + const progress_bar_timeout_seconds = 15; + + // Remove an old timer that hasn't fired yet. + self.stopProgressBarTimer(); + + self.progress_bar_timer = glib.timeoutAdd( + progress_bar_timeout_seconds * std.time.ms_per_s, + handleProgressBarTimeout, + self, + ); +} + +/// Stop any existing timer for removing the progress bar. +fn stopProgressBarTimer(self: *ProgressBar) void { + if (self.progress_bar_timer) |timer| { + if (glib.Source.remove(timer) == 0) { + log.warn("unable to remove progress bar timer", .{}); + } + self.progress_bar_timer = null; + } +} + +/// The progress bar hasn't been updated by the TUI recently, remove it. +fn handleProgressBarTimeout(ud: ?*anyopaque) callconv(.c) c_int { + const self: *ProgressBar = @ptrCast(@alignCast(ud.?)); + + self.progress_bar_timer = null; + self.removeProgressBar(); + + return @intFromBool(glib.SOURCE_REMOVE); +} diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index a468bd48d..4eb86ce79 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -37,6 +37,7 @@ const CloseDialog = @import("CloseDialog.zig"); const inspectorpkg = @import("inspector.zig"); const gtk_key = @import("key.zig"); const Builder = @import("Builder.zig"); +const ProgressBar = @import("ProgressBar.zig"); const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk_surface); @@ -309,6 +310,9 @@ precision_scroll: bool = false, /// Flag indicating whether the surface is in secure input mode. is_secure_input: bool = false, +/// Structure for managing GUI progress bar +progress_bar: ProgressBar, + /// The state of the key event while we're doing IM composition. /// See gtkKeyPressed for detailed descriptions. pub const IMKeyEvent = enum { @@ -517,6 +521,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .im_context = im_context, .cgroup_path = cgroup_path, .context_menu = undefined, + .progress_bar = .init(self), }; errdefer self.* = undefined; @@ -736,6 +741,9 @@ pub fn deinit(self: *Surface) void { // We don't allocate anything if we aren't realized. if (!self.realized) return; + // Cleanup the progress bar. + self.progress_bar.deinit(); + // Delete our inspector if we have one self.controlInspector(.hide); diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index f3106105f..777ab3810 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -105,3 +105,12 @@ banner.child_exited_abnormally revealer widget { /* after GTK 4.16 is a requirement, switch to the following: /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ } + +/* +* Change the color of an error progressbar +*/ +progressbar.error trough progress { + background-color: rgb(192, 28, 40); + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background-color: var(--error-bg-color); */ +} diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 1cd53b66a..250675bbb 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -92,6 +92,9 @@ pub const Message = union(enum) { /// The terminal encountered a bell character. ring_bell, + /// Report the progress of an action using a GUI element + progress_report: terminal.osc.Command.ProgressReport, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index d0b59e834..9c35bd07e 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -158,10 +158,7 @@ pub const Command = union(enum) { }, /// Set progress state (OSC 9;4) - progress: struct { - state: ProgressState, - progress: ?u8 = null, - }, + progress_report: ProgressReport, /// Wait input (OSC 9;5) wait_input: void, @@ -206,12 +203,31 @@ pub const Command = union(enum) { report: Kind, }; - pub const ProgressState = enum { - remove, - set, - @"error", - indeterminate, - pause, + pub const ProgressReport = struct { + // sync with ghostty_terminal_osc_command_progressreport_state_e in include/ghostty.h + pub const State = enum(c_int) { + remove, + set, + @"error", + indeterminate, + pause, + }; + + state: State, + progress: ?u8 = null, + + // sync with ghostty_terminal_osc_command_progressreport_s in include/ghostty.h + pub const C = extern struct { + state: c_int, + progress: i8, + }; + + pub fn cval(self: ProgressReport) C { + return .{ + .state = @intFromEnum(self.state), + .progress = if (self.progress) |progress| @intCast(std.math.clamp(progress, 0, 100)) else -1, + }; + } }; comptime { @@ -973,7 +989,7 @@ pub const Parser = struct { .conemu_progress_prestate => switch (c) { ';' => { - self.command = .{ .progress = .{ + self.command = .{ .progress_report = .{ .state = undefined, } }; self.state = .conemu_progress_state; @@ -983,27 +999,27 @@ pub const Parser = struct { .conemu_progress_state => switch (c) { '0' => { - self.command.progress.state = .remove; + self.command.progress_report.state = .remove; self.state = .swallow; self.complete = true; }, '1' => { - self.command.progress.state = .set; - self.command.progress.progress = 0; + self.command.progress_report.state = .set; + self.command.progress_report.progress = 0; self.state = .conemu_progress_prevalue; }, '2' => { - self.command.progress.state = .@"error"; + self.command.progress_report.state = .@"error"; self.complete = true; self.state = .conemu_progress_prevalue; }, '3' => { - self.command.progress.state = .indeterminate; + self.command.progress_report.state = .indeterminate; self.complete = true; self.state = .swallow; }, '4' => { - self.command.progress.state = .pause; + self.command.progress_report.state = .pause; self.complete = true; self.state = .conemu_progress_prevalue; }, @@ -1026,7 +1042,7 @@ pub const Parser = struct { // If we aren't a set substate, then we don't care // about the value. - const p = &self.command.progress; + const p = &self.command.progress_report; if (p.state != .set and p.state != .@"error" and p.state != .pause) break :value; if (p.state == .set) @@ -2901,9 +2917,9 @@ test "OSC: OSC9 progress set" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .set); - try testing.expect(cmd.progress.progress == 100); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .set); + try testing.expect(cmd.progress_report.progress == 100); } test "OSC: OSC9 progress set overflow" { @@ -2915,9 +2931,9 @@ test "OSC: OSC9 progress set overflow" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .set); - try testing.expect(cmd.progress.progress == 100); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .set); + try testing.expect(cmd.progress_report.progress == 100); } test "OSC: OSC9 progress set single digit" { @@ -2929,9 +2945,9 @@ test "OSC: OSC9 progress set single digit" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .set); - try testing.expect(cmd.progress.progress == 9); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .set); + try testing.expect(cmd.progress_report.progress == 9); } test "OSC: OSC9 progress set double digit" { @@ -2943,9 +2959,9 @@ test "OSC: OSC9 progress set double digit" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .set); - try testing.expect(cmd.progress.progress == 94); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .set); + try testing.expect(cmd.progress_report.progress == 94); } test "OSC: OSC9 progress set extra semicolon ignored" { @@ -2957,9 +2973,9 @@ test "OSC: OSC9 progress set extra semicolon ignored" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .set); - try testing.expect(cmd.progress.progress == 100); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .set); + try testing.expect(cmd.progress_report.progress == 100); } test "OSC: OSC9 progress remove with no progress" { @@ -2971,9 +2987,9 @@ test "OSC: OSC9 progress remove with no progress" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .remove); - try testing.expect(cmd.progress.progress == null); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .remove); + try testing.expect(cmd.progress_report.progress == null); } test "OSC: OSC9 progress remove with double semicolon" { @@ -2985,9 +3001,9 @@ test "OSC: OSC9 progress remove with double semicolon" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .remove); - try testing.expect(cmd.progress.progress == null); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .remove); + try testing.expect(cmd.progress_report.progress == null); } test "OSC: OSC9 progress remove ignores progress" { @@ -2999,9 +3015,9 @@ test "OSC: OSC9 progress remove ignores progress" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .remove); - try testing.expect(cmd.progress.progress == null); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .remove); + try testing.expect(cmd.progress_report.progress == null); } test "OSC: OSC9 progress remove extra semicolon" { @@ -3013,8 +3029,8 @@ test "OSC: OSC9 progress remove extra semicolon" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .remove); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .remove); } test "OSC: OSC9 progress error" { @@ -3026,9 +3042,9 @@ test "OSC: OSC9 progress error" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .@"error"); - try testing.expect(cmd.progress.progress == null); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .@"error"); + try testing.expect(cmd.progress_report.progress == null); } test "OSC: OSC9 progress error with progress" { @@ -3040,9 +3056,9 @@ test "OSC: OSC9 progress error with progress" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .@"error"); - try testing.expect(cmd.progress.progress == 100); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .@"error"); + try testing.expect(cmd.progress_report.progress == 100); } test "OSC: OSC9 progress pause" { @@ -3054,9 +3070,9 @@ test "OSC: OSC9 progress pause" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .pause); - try testing.expect(cmd.progress.progress == null); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .pause); + try testing.expect(cmd.progress_report.progress == null); } test "OSC: OSC9 progress pause with progress" { @@ -3068,9 +3084,9 @@ test "OSC: OSC9 progress pause with progress" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .progress); - try testing.expect(cmd.progress.state == .pause); - try testing.expect(cmd.progress.progress == 100); + try testing.expect(cmd == .progress_report); + try testing.expect(cmd.progress_report.state == .pause); + try testing.expect(cmd.progress_report.progress == 100); } test "OSC: OSC9 conemu wait input" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index fd30720b3..ec7296490 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1590,7 +1590,14 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress, .sleep, .show_message_box, .change_conemu_tab_title, .wait_input => { + .progress_report => |v| { + if (@hasDecl(T, "handleProgressReport")) { + try self.handler.handleProgressReport(v); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .sleep, .show_message_box, .change_conemu_tab_title, .wait_input => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 039b11c03..002ccdb39 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1518,4 +1518,9 @@ pub const StreamHandler = struct { // processed stream will queue a render once it is done processing // the read() syscall. } + + /// Display a GUI progress report. + pub fn handleProgressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) error{}!void { + self.surfaceMessageWriter(.{ .progress_report = report }); + } };