core/gtk: add support for displaying a progress bar with OSC 9;4 (#7975)

Ghostty has had support for a while (since PR #3124) for parsing
progress reports but never did anything with them. This PR adds the core
infrastructure and an implementation for GTK.

On GTK, the progress bar will show up as a thin bar along the top of the
terminal. Under normal circumstances it will use whatever you have set
as your accent color. If the progam sending the progress report
indicates an error, it will change to a reddish color.
This commit is contained in:
Jeffrey C. Ollie
2025-07-18 09:15:36 -05:00
committed by GitHub
12 changed files with 314 additions and 58 deletions

View File

@ -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 {

View File

@ -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();

View File

@ -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

View File

@ -381,6 +381,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.progress_report,
=> {
log.warn("unimplemented action={}", .{action});
return false;

View File

@ -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(),

View File

@ -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);
}

View File

@ -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);

View File

@ -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); */
}

View File

@ -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,

View File

@ -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" {

View File

@ -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});
},
}

View File

@ -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 });
}
};