diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6a6adb494..0f843c7ce 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = ""; }; A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = ""; }; + A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -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 */, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index ff265189b..37b1a362d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -99,10 +99,13 @@ extension Ghostty.Action { 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 - } + } +} + +// 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 } } diff --git a/macos/Sources/Ghostty/SurfaceProgressBar.swift b/macos/Sources/Ghostty/SurfaceProgressBar.swift new file mode 100644 index 000000000..82d26e681 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceProgressBar.swift @@ -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 + } + } +} + + diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 38efef646..25a142d65 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -114,11 +114,17 @@ 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) // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! @@ -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.