diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift new file mode 100644 index 00000000..7438fafc --- /dev/null +++ b/supacode/Features/Terminal/Views/GhosttySurfaceProgressBar.swift @@ -0,0 +1,77 @@ +import GhosttyKit +import SwiftUI + +struct GhosttySurfaceProgressBar: View { + let progressState: ghostty_action_progress_report_state_e + let progressValue: Int? + + @State private var position: CGFloat = 0 + + var body: some View { + let color: Color = switch progressState { + case GHOSTTY_PROGRESS_STATE_ERROR: .red + case GHOSTTY_PROGRESS_STATE_PAUSE: .orange + default: .accentColor + } + let progress: Int? = + progressValue ?? (progressState == GHOSTTY_PROGRESS_STATE_PAUSE ? 100 : nil) + let accessibilityLabel: String = switch progressState { + case GHOSTTY_PROGRESS_STATE_ERROR: "Terminal progress - Error" + case GHOSTTY_PROGRESS_STATE_PAUSE: "Terminal progress - Paused" + case GHOSTTY_PROGRESS_STATE_INDETERMINATE: "Terminal progress - In progress" + default: "Terminal progress" + } + let accessibilityValue: String = + if let progress { + "\(progress) percent complete" + } else { + switch progressState { + case GHOSTTY_PROGRESS_STATE_ERROR: "Operation failed" + case GHOSTTY_PROGRESS_STATE_PAUSE: "Operation paused at completion" + case GHOSTTY_PROGRESS_STATE_INDETERMINATE: "Operation in progress" + default: "Indeterminate progress" + } + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + if let progress { + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * CGFloat(progress) / 100, + height: geometry.size.height + ) + .animation(.easeInOut(duration: 0.2), value: progress) + } else { + ZStack(alignment: .leading) { + Rectangle() + .fill(color.opacity(0.3)) + Rectangle() + .fill(color) + .frame(width: geometry.size.width * 0.25, height: geometry.size.height) + .offset(x: position * (geometry.size.width * 0.75)) + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + position = 1 + } + } + .onDisappear { + position = 0 + } + } + } + } + .frame(height: 2) + .clipped() + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } +} diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceProgressOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceProgressOverlay.swift new file mode 100644 index 00000000..1027f593 --- /dev/null +++ b/supacode/Features/Terminal/Views/GhosttySurfaceProgressOverlay.swift @@ -0,0 +1,20 @@ +import GhosttyKit +import SwiftUI + +struct GhosttySurfaceProgressOverlay: View { + @Bindable var state: GhosttySurfaceState + + init(state: GhosttySurfaceState) { + self._state = Bindable(state) + } + + var body: some View { + if let progressState = state.progressState, progressState != GHOSTTY_PROGRESS_STATE_REMOVE { + GhosttySurfaceProgressBar( + progressState: progressState, + progressValue: state.progressValue + ) + .transition(.opacity) + } + } +} diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index cfc08f9c..1f971dc0 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -84,6 +84,9 @@ struct TerminalSplitTreeView: View { GeometryReader { geometry in GhosttyTerminalView(surfaceView: surfaceView) .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state) + } .overlay(alignment: .top) { if isSplit { DragHandle(surfaceView: surfaceView) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 50a48c2a..9efc1725 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -13,6 +13,11 @@ final class GhosttySurfaceBridge { var onCloseTab: ((ghostty_action_close_tab_mode_e) -> Bool)? var onGotoTab: ((ghostty_action_goto_tab_e) -> Bool)? var onProgressReport: ((ghostty_action_progress_report_state_e) -> Void)? + private var progressResetTask: Task? + + deinit { + progressResetTask?.cancel() + } func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if let handled = handleAppAction(action) { return handled } @@ -171,8 +176,22 @@ final class GhosttySurfaceBridge { switch action.tag { case GHOSTTY_ACTION_PROGRESS_REPORT: let report = action.action.progress_report - state.progressState = report.state + progressResetTask?.cancel() state.progressValue = report.progress == -1 ? nil : Int(report.progress) + if report.state == GHOSTTY_PROGRESS_STATE_REMOVE { + state.progressState = nil + state.progressValue = nil + progressResetTask = nil + } else { + state.progressState = report.state + progressResetTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(15)) + guard let self, !Task.isCancelled else { return } + self.state.progressState = nil + self.state.progressValue = nil + self.onProgressReport?(GHOSTTY_PROGRESS_STATE_REMOVE) + } + } onProgressReport?(report.state) return true