macos: custom progress bar to workaround macOS 26 ProgressView bugs

Fixes #8731

The progress view in macOS 26 is broken in ways we can't work around
directly. Instead, we must create our own custom progress bar. Luckily,
our usage of the progress view is very simple.

Amp threads:
https://ampcode.com/threads/T-88b550b7-5e0d-4ab9-97d9-36fb63d18f21
https://ampcode.com/threads/T-721d6085-21d5-497d-b6ac-9f203aae0b94
This commit is contained in:
Mitchell Hashimoto
2025-09-18 09:53:21 -07:00
parent 6ed00b1217
commit 058d6808c1
4 changed files with 136 additions and 51 deletions

View File

@@ -143,6 +143,7 @@
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
@@ -293,6 +294,7 @@
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@@ -492,6 +494,7 @@
isa = PBXGroup;
children = (
A55B7BB729B6F53A0055DE60 /* Package.swift */,
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
@@ -892,6 +895,7 @@
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */,
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,

View File

@@ -99,10 +99,13 @@ extension Ghostty.Action {
let state: State
let progress: UInt8?
}
}
// Putting the initializer in an extension preserves the automatic one.
extension Ghostty.Action.ProgressReport {
init(c: ghostty_action_progress_report_s) {
self.state = State(c.state)
self.progress = c.progress >= 0 ? UInt8(c.progress) : nil
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
/// The progress bar to show a surface progress report. We implement this from scratch because the
/// standard ProgressView is broken on macOS 26 and this is simple anyways and gives us a ton of
/// control.
struct SurfaceProgressBar: View {
let report: Ghostty.Action.ProgressReport
private var color: Color {
switch report.state {
case .error: return .red
case .pause: return .orange
default: return .accentColor
}
}
private var progress: UInt8? {
// If we have an explicit progress use that.
if let v = report.progress { return v }
// Otherwise, if we're in the pause state, we act as if we're at 100%.
if report.state == .pause { return 100 }
return nil
}
private var accessibilityLabel: String {
switch report.state {
case .error: return "Terminal progress - Error"
case .pause: return "Terminal progress - Paused"
case .indeterminate: return "Terminal progress - In progress"
default: return "Terminal progress"
}
}
private var accessibilityValue: String {
if let progress {
return "\(progress) percent complete"
} else {
switch report.state {
case .error: return "Operation failed"
case .pause: return "Operation paused at completion"
case .indeterminate: return "Operation in progress"
default: return "Indeterminate progress"
}
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
if let progress {
// Determinate progress bar with specific percentage
Rectangle()
.fill(color)
.frame(
width: geometry.size.width * CGFloat(progress) / 100,
height: geometry.size.height
)
.animation(.easeInOut(duration: 0.2), value: progress)
} else {
// Indeterminate states without specific progress - all use bouncing animation
BouncingProgressBar(color: color)
}
}
}
.frame(height: 2)
.clipped()
.allowsHitTesting(false)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.updatesFrequently)
.accessibilityLabel(accessibilityLabel)
.accessibilityValue(accessibilityValue)
}
}
/// Bouncing progress bar for indeterminate states
private struct BouncingProgressBar: View {
let color: Color
@State private var position: CGFloat = 0
private let barWidthRatio: CGFloat = 0.25
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(color.opacity(0.3))
Rectangle()
.fill(color)
.frame(
width: geometry.size.width * barWidthRatio,
height: geometry.size.height
)
.offset(x: position * (geometry.size.width * (1 - barWidthRatio)))
}
}
.onAppear {
withAnimation(
.easeInOut(duration: 1.2)
.repeatForever(autoreverses: true)
) {
position = 1
}
}
.onDisappear {
position = 0
}
}
}

View File

@@ -114,9 +114,15 @@ extension Ghostty {
}
.ghosttySurfaceView(surfaceView)
// Progress report overlay
if let progressReport = surfaceView.progressReport {
ProgressReportOverlay(report: progressReport)
// Progress report
if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
VStack(spacing: 0) {
SurfaceProgressBar(report: progressReport)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.allowsHitTesting(false)
.transition(.opacity)
}
#if canImport(AppKit)
@@ -272,48 +278,7 @@ 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.