macOS: Progress bar for OSC9 progress reports

This commit is contained in:
Mitchell Hashimoto
2025-08-31 19:54:26 -07:00
parent a41ec17b61
commit 0b58830882
8 changed files with 151 additions and 7 deletions

View File

@@ -722,15 +722,15 @@ typedef enum {
GHOSTTY_PROGRESS_STATE_ERROR,
GHOSTTY_PROGRESS_STATE_INDETERMINATE,
GHOSTTY_PROGRESS_STATE_PAUSE,
} ghostty_terminal_osc_command_progressreport_state_e;
} ghostty_action_progress_report_state_e;
// terminal.osc.Command.ProgressReport.C
typedef struct {
ghostty_terminal_osc_command_progressreport_state_e state;
ghostty_action_progress_report_state_e state;
// -1 if no progress was reported, otherwise 0-100 indicating percent
// completeness.
int8_t progress;
} ghostty_terminal_osc_command_progressreport_s;
} ghostty_action_progress_report_s;
// apprt.Action.Key
typedef enum {
@@ -817,7 +817,7 @@ typedef union {
ghostty_action_open_url_s open_url;
ghostty_action_close_tab_mode_e close_tab_mode;
ghostty_surface_message_childexited_s child_exited;
ghostty_terminal_osc_command_progressreport_s progress_report;
ghostty_action_progress_report_s progress_report;
} ghostty_action_u;
typedef struct {

View File

@@ -127,6 +127,7 @@
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; };
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; };
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; };
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */; };
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
@@ -987,6 +988,7 @@
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */,
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,

View File

@@ -70,4 +70,39 @@ extension Ghostty.Action {
}
}
}
struct ProgressReport {
enum State {
case remove
case set
case error
case indeterminate
case pause
init(_ c: ghostty_action_progress_report_state_e) {
switch c {
case GHOSTTY_PROGRESS_STATE_REMOVE:
self = .remove
case GHOSTTY_PROGRESS_STATE_SET:
self = .set
case GHOSTTY_PROGRESS_STATE_ERROR:
self = .error
case GHOSTTY_PROGRESS_STATE_INDETERMINATE:
self = .indeterminate
case GHOSTTY_PROGRESS_STATE_PAUSE:
self = .pause
default:
self = .remove
}
}
}
let state: State
let progress: UInt8?
init(c: ghostty_action_progress_report_s) {
self.state = State(c.state)
self.progress = c.progress >= 0 ? UInt8(c.progress) : nil
}
}
}

View File

@@ -543,6 +543,9 @@ extension Ghostty {
case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence)
case GHOSTTY_ACTION_PROGRESS_REPORT:
progressReport(app, target: target, v: action.action.progress_report)
case GHOSTTY_ACTION_CONFIG_CHANGE:
configChange(app, target: target, v: action.action.config_change)
@@ -1523,6 +1526,33 @@ extension Ghostty {
assertionFailure()
}
}
private static func progressReport(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_progress_report_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("progress report does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
let progressReport = Ghostty.Action.ProgressReport(c: v)
DispatchQueue.main.async {
if progressReport.state == .remove {
surfaceView.progressReport = nil
} else {
surfaceView.progressReport = progressReport
}
}
default:
assertionFailure()
}
}
private static func configReload(
_ app: ghostty_app_t,

View File

@@ -113,6 +113,11 @@ extension Ghostty {
}
}
.ghosttySurfaceView(surfaceView)
// Progress report overlay
if let progressReport = surfaceView.progressReport {
ProgressReportOverlay(report: progressReport)
}
#if canImport(AppKit)
// If we are in the middle of a key sequence, then we show a visual element. We only
@@ -267,6 +272,49 @@ extension Ghostty {
}
}
// Progress report overlay that shows a progress bar at the top of the terminal
struct ProgressReportOverlay: View {
let report: Action.ProgressReport
@ViewBuilder
private var progressBar: some View {
if let progress = report.progress {
// Determinate progress bar
ProgressView(value: Double(progress), total: 100)
.progressViewStyle(.linear)
.tint(report.state == .error ? .red : report.state == .pause ? .orange : nil)
.animation(.easeInOut(duration: 0.2), value: progress)
} else {
// Indeterminate states
switch report.state {
case .indeterminate:
ProgressView()
.progressViewStyle(.linear)
case .error:
ProgressView()
.progressViewStyle(.linear)
.tint(.red)
case .pause:
Rectangle().fill(Color.orange)
default:
EmptyView()
}
}
}
var body: some View {
VStack(spacing: 0) {
progressBar
.scaleEffect(x: 1, y: 0.5, anchor: .center)
.frame(height: 2)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsHitTesting(false)
}
}
// This is the resize overlay that shows on top of a surface to show the current
// size during a resize operation.
struct SurfaceResizeOverlay: View {

View File

@@ -41,6 +41,23 @@ extension Ghostty {
// The hovered URL string
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
progressReportTimer = nil
// If we have a new progress report, start a timer to remove it after 15 seconds
if progressReport != nil {
progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
self?.progressReport = nil
self?.progressReportTimer = nil
}
}
}
}
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
@@ -142,6 +159,9 @@ extension Ghostty {
// A timer to fallback to ghost emoji if no title is set within the grace period
private var titleFallbackTimer: Timer?
// Timer to remove progress report after 15 seconds
private var progressReportTimer: Timer?
// This is the title from the terminal. This is nil if we're currently using
// the terminal title as the main title property. If the title is set manually
@@ -348,6 +368,9 @@ extension Ghostty {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
// Cancel progress report timer
progressReportTimer?.invalidate()
}
func focusDidChange(_ focused: Bool) {

View File

@@ -30,6 +30,9 @@ extension Ghostty {
// The hovered URL
@Published var hoverUrl: String? = nil
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? = nil
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.

View File

@@ -211,7 +211,6 @@ pub const Command = union(enum) {
};
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,
@@ -223,7 +222,7 @@ pub const Command = union(enum) {
state: State,
progress: ?u8 = null,
// sync with ghostty_terminal_osc_command_progressreport_s in include/ghostty.h
// sync with ghostty_action_progress_report_s
pub const C = extern struct {
state: c_int,
progress: i8,
@@ -232,7 +231,11 @@ pub const Command = union(enum) {
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,
.progress = if (self.progress) |progress| @intCast(std.math.clamp(
progress,
0,
100,
)) else -1,
};
}
};