-
-
Notifications
You must be signed in to change notification settings - Fork 502
feat(history): copy entries via double-click/⌘C/menu + keyboard navigation #568
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0a9899b
1707aa7
a93cd6c
2cb0d97
ecc6525
22754c1
4cd1f5d
bc16449
b21a280
b1e16db
4a9258c
4074066
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import AppKit | ||
| import SwiftUI | ||
|
|
||
| /// A small, transient "Copied" confirmation that appears next to the mouse | ||
| /// cursor and fades away — the pattern people expect for a copy action, so the | ||
| /// feedback shows up where the user is already looking instead of docked to a | ||
| /// row. Presented in a borderless, non-activating floating panel so it never | ||
| /// steals focus (a subsequent ⌘V still pastes into the user's target app). | ||
| /// | ||
| /// Shared by every copy action in the Transcription History view (double-click, | ||
| /// ⌘C, the Copy button, and the right-click menu). | ||
| @MainActor | ||
| final class CursorCopyToast { | ||
| static let shared = CursorCopyToast() | ||
|
|
||
| private var panel: NSPanel? | ||
| private var dismissTask: Task<Void, Never>? | ||
| private var generation = 0 | ||
|
|
||
| private init() {} | ||
|
|
||
| /// Show the toast at the current cursor location. Safe to call repeatedly; | ||
| /// each call resets the auto-dismiss timer. | ||
| func show(_ text: String = "Copied") { | ||
| let panel = self.panel ?? self.makePanel() | ||
| self.panel = panel | ||
| self.generation &+= 1 | ||
|
|
||
| let host = NSHostingView(rootView: CursorCopyToastView(text: text)) | ||
| host.layout() | ||
| let size = host.fittingSize | ||
| panel.contentView = host | ||
| panel.setContentSize(size) | ||
|
|
||
| // Position just above-right of the cursor, clamped to its screen. | ||
| let mouse = NSEvent.mouseLocation | ||
| var origin = CGPoint(x: mouse.x + 14, y: mouse.y + 14) | ||
| let screen = NSScreen.screens.first(where: { $0.frame.contains(mouse) }) ?? NSScreen.main | ||
| if let visible = screen?.visibleFrame { | ||
| origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) | ||
| origin.y = min(max(origin.y, visible.minY + 8), visible.maxY - size.height - 8) | ||
| } | ||
| panel.setFrameOrigin(origin) | ||
|
|
||
| panel.alphaValue = 0 | ||
| panel.orderFrontRegardless() | ||
| NSAnimationContext.runAnimationGroup { context in | ||
| context.duration = 0.12 | ||
| panel.animator().alphaValue = 1 | ||
| } | ||
|
|
||
| self.dismissTask?.cancel() | ||
| self.dismissTask = Task { [weak self] in | ||
| try? await Task.sleep(for: .seconds(0.9)) | ||
| guard !Task.isCancelled else { return } | ||
| self?.dismiss() | ||
| } | ||
| } | ||
|
|
||
| private func dismiss() { | ||
| guard let panel = self.panel else { return } | ||
| let dismissGeneration = self.generation | ||
| NSAnimationContext.runAnimationGroup { context in | ||
| context.duration = 0.2 | ||
| panel.animator().alphaValue = 0 | ||
| } completionHandler: { [weak self] in | ||
| // Skip hiding if a newer show() re-displayed the toast during the fade. | ||
| guard let self, self.generation == dismissGeneration else { return } | ||
| panel.orderOut(nil) | ||
| } | ||
| } | ||
|
|
||
| private func makePanel() -> NSPanel { | ||
| let panel = NSPanel( | ||
| contentRect: .zero, | ||
| styleMask: [.borderless, .nonactivatingPanel], | ||
| backing: .buffered, | ||
| defer: true | ||
| ) | ||
| panel.isFloatingPanel = true | ||
| panel.level = .floating | ||
| panel.backgroundColor = .clear | ||
| panel.isOpaque = false | ||
| panel.hasShadow = true | ||
| panel.ignoresMouseEvents = true | ||
| panel.hidesOnDeactivate = false | ||
| panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] | ||
| return panel | ||
| } | ||
| } | ||
|
|
||
| private struct CursorCopyToastView: View { | ||
| let text: String | ||
|
|
||
| var body: some View { | ||
| HStack(spacing: 6) { | ||
| Image(systemName: "checkmark.circle.fill") | ||
| .font(.system(size: 12, weight: .semibold)) | ||
| Text(self.text) | ||
| .font(.system(size: 12, weight: .semibold)) | ||
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.vertical, 8) | ||
| .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) | ||
| .overlay( | ||
| RoundedRectangle(cornerRadius: 8) | ||
| .strokeBorder(.primary.opacity(0.08)) | ||
| ) | ||
| .fixedSize() | ||
| .padding(4) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import Foundation | ||
|
|
||
| /// Decides what a copy *gesture* (double-click) in the Transcription History | ||
| /// list places on the pasteboard. Keeps the gesture wiring in | ||
| /// `TranscriptionHistoryView` thin and gives the behavior a unit-testable seam. | ||
| enum HistoryCopy { | ||
| /// Text a copy gesture should place on the pasteboard for `entry`, or `nil` | ||
| /// when the entry has nothing copyable. Delegates to | ||
| /// `TranscriptionHistoryEntry.clipboardText` — the same processed-then-raw | ||
| /// selection PR #450 centralized for the menu-bar copy. | ||
| static func text(for entry: TranscriptionHistoryEntry) -> String? { | ||
| entry.clipboardText | ||
| } | ||
|
|
||
| /// The index to select when the user presses ↑ (`moveUp == true`) or ↓ in | ||
| /// the History list, clamped to the list bounds. Returns `nil` for an empty | ||
| /// list. A `nil` current selection moves to the first row either way. | ||
| static func nextIndex(current: Int?, count: Int, moveUp: Bool) -> Int? { | ||
| guard count > 0 else { return nil } | ||
| if moveUp { | ||
| return max(0, (current ?? 0) - 1) | ||
| } | ||
| return min(count - 1, (current ?? -1) + 1) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,22 @@ struct TranscriptionHistoryView: View { | |
| @State private var showReportConfirmation: Bool = false | ||
| @State private var selectedReportEntry: TranscriptionHistoryEntry? | ||
| @State private var selectedEntryID: UUID? | ||
| @State private var copiedMark: CopiedMark? | ||
| @State private var copiedResetTask: Task<Void, Never>? | ||
| @FocusState private var listFocused: Bool | ||
|
|
||
| /// A detail-pane copy button that just copied. Scoped to its entry so the | ||
| /// inline confirmation can never leak onto a different entry. | ||
| private enum CopiedButton: Equatable { | ||
| case primary | ||
| case raw | ||
| case both | ||
| } | ||
|
|
||
| private struct CopiedMark: Equatable { | ||
| let entryID: UUID | ||
| let button: CopiedButton | ||
| } | ||
|
|
||
| private var filteredEntries: [TranscriptionHistoryEntry] { | ||
| self.historyStore.search(query: self.searchQuery) | ||
|
|
@@ -120,14 +136,26 @@ struct TranscriptionHistoryView: View { | |
| // MARK: - Entry List | ||
|
|
||
| private var entryListView: some View { | ||
| ScrollView { | ||
| LazyVStack(spacing: 2) { | ||
| ForEach(self.filteredEntries) { entry in | ||
| self.entryRow(entry) | ||
| ScrollViewReader { proxy in | ||
| ScrollView { | ||
| LazyVStack(spacing: 2) { | ||
| ForEach(self.filteredEntries) { entry in | ||
| self.entryRow(entry) | ||
| .id(entry.id) | ||
| } | ||
| } | ||
| .padding(.horizontal, 8) | ||
| .padding(.vertical, 6) | ||
| } | ||
| .focusable() | ||
| .focusEffectDisabled() | ||
| .focused(self.$listFocused) | ||
| .onMoveCommand { direction in | ||
| self.moveSelection(direction, scrollProxy: proxy) | ||
| } | ||
| .onCopyCommand { | ||
| self.copyCommandProviders() | ||
| } | ||
| .padding(.horizontal, 8) | ||
| .padding(.vertical, 6) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -138,6 +166,7 @@ struct TranscriptionHistoryView: View { | |
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| self.selectedEntryID = entry.id | ||
| } | ||
| self.listFocused = true | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 4) { | ||
| // Top row: App name and time | ||
|
|
@@ -197,22 +226,35 @@ struct TranscriptionHistoryView: View { | |
| .contentShape(Rectangle()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .simultaneousGesture( | ||
| TapGesture(count: 2).onEnded { | ||
| self.selectedEntryID = entry.id | ||
| self.listFocused = true | ||
| if let text = HistoryCopy.text(for: entry) { | ||
| self.copyToClipboard(text) | ||
| self.flashCopied() | ||
|
Comment on lines
+233
to
+235
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new double-click path copies Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| ) | ||
| .contextMenu { | ||
| Button { | ||
| self.copyToClipboard(entry.processedText) | ||
| self.copyToClipboard(entry.clipboardText ?? entry.processedText) | ||
| self.flashCopied() | ||
| } label: { | ||
| Label(entry.wasAIProcessed ? "Copy AI Text" : "Copy Text", systemImage: "doc.on.doc") | ||
| } | ||
|
|
||
| if entry.wasAIProcessed { | ||
| Button { | ||
| self.copyToClipboard(entry.rawText) | ||
| self.flashCopied() | ||
| } label: { | ||
| Label("Copy Raw Text", systemImage: "doc.on.doc.fill") | ||
| } | ||
|
|
||
| Button { | ||
| self.copyToClipboard(self.combinedText(for: entry)) | ||
| self.flashCopied() | ||
| } label: { | ||
| Label("Copy Both", systemImage: "doc.on.doc") | ||
| } | ||
|
|
@@ -333,10 +375,15 @@ struct TranscriptionHistoryView: View { | |
| Spacer() | ||
|
|
||
| Button { | ||
| self.copyToClipboard(entry.processedText) | ||
| self.copyToClipboard(entry.clipboardText ?? entry.processedText) | ||
| self.markButtonCopied(.primary, for: entry) | ||
| } label: { | ||
| Label(entry.wasAIProcessed ? "Copy AI" : "Copy", systemImage: "doc.on.doc") | ||
| .font(.system(size: 12, weight: .medium)) | ||
| self.copyButtonLabel( | ||
| .primary, | ||
| for: entry, | ||
| title: entry.wasAIProcessed ? "Copy AI" : "Copy", | ||
| systemImage: "doc.on.doc" | ||
| ) | ||
| } | ||
| .buttonStyle(.bordered) | ||
| .controlSize(.small) | ||
|
|
@@ -374,18 +421,18 @@ struct TranscriptionHistoryView: View { | |
| if entry.wasAIProcessed { | ||
| Button { | ||
| self.copyToClipboard(entry.rawText) | ||
| self.markButtonCopied(.raw, for: entry) | ||
| } label: { | ||
| Label("Raw", systemImage: "doc.on.doc.fill") | ||
| .font(.system(size: 12, weight: .medium)) | ||
| self.copyButtonLabel(.raw, for: entry, title: "Raw", systemImage: "doc.on.doc.fill") | ||
| } | ||
| .buttonStyle(.bordered) | ||
| .controlSize(.small) | ||
|
|
||
| Button { | ||
| self.copyToClipboard(self.combinedText(for: entry)) | ||
| self.markButtonCopied(.both, for: entry) | ||
| } label: { | ||
| Label("Both", systemImage: "doc.on.doc") | ||
| .font(.system(size: 12, weight: .medium)) | ||
| self.copyButtonLabel(.both, for: entry, title: "Both", systemImage: "doc.on.doc") | ||
| } | ||
| .buttonStyle(.bordered) | ||
| .controlSize(.small) | ||
|
|
@@ -564,6 +611,73 @@ struct TranscriptionHistoryView: View { | |
| NSPasteboard.general.setString(text, forType: .string) | ||
| } | ||
|
|
||
| /// Cursor toast — for the copy paths with no button to morph: double-click, | ||
| /// ⌘C, and the right-click menu (which dismisses the moment it's clicked). | ||
| private func flashCopied() { | ||
| CursorCopyToast.shared.show() | ||
| } | ||
|
|
||
| /// Inline confirmation for the detail-pane copy buttons: the button itself | ||
| /// briefly becomes "Copied", so the feedback lands on the control the user | ||
| /// actually pressed. | ||
| private func markButtonCopied(_ button: CopiedButton, for entry: TranscriptionHistoryEntry) { | ||
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| self.copiedMark = CopiedMark(entryID: entry.id, button: button) | ||
| } | ||
| self.copiedResetTask?.cancel() | ||
| self.copiedResetTask = Task { @MainActor in | ||
| try? await Task.sleep(for: .seconds(2)) | ||
| guard !Task.isCancelled else { return } | ||
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| self.copiedMark = nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private func copyButtonLabel( | ||
| _ button: CopiedButton, | ||
| for entry: TranscriptionHistoryEntry, | ||
| title: String, | ||
| systemImage: String | ||
| ) -> some View { | ||
| let copied = self.copiedMark == CopiedMark(entryID: entry.id, button: button) | ||
| Label(copied ? "Copied" : title, systemImage: copied ? "checkmark" : systemImage) | ||
| .font(.system(size: 12, weight: .medium)) | ||
| } | ||
|
|
||
| private func moveSelection(_ direction: MoveCommandDirection, scrollProxy: ScrollViewProxy) { | ||
| let entries = self.filteredEntries | ||
| let currentIndex = self.selectedEntryID.flatMap { id in | ||
| entries.firstIndex(where: { $0.id == id }) | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| let moveUp: Bool | ||
| switch direction { | ||
| case .up: | ||
| moveUp = true | ||
| case .down: | ||
| moveUp = false | ||
| default: | ||
| return | ||
| } | ||
| guard let newIndex = HistoryCopy.nextIndex(current: currentIndex, count: entries.count, moveUp: moveUp) else { | ||
| return | ||
| } | ||
| let id = entries[newIndex].id | ||
| self.selectedEntryID = id | ||
| withAnimation(.easeInOut(duration: 0.15)) { | ||
| scrollProxy.scrollTo(id, anchor: .center) | ||
| } | ||
| } | ||
|
|
||
| private func copyCommandProviders() -> [NSItemProvider] { | ||
| guard self.selectedEntryID != nil, | ||
| let entry = self.selectedEntry, | ||
| let text = HistoryCopy.text(for: entry) else { return [] } | ||
| self.flashCopied() | ||
| return [NSItemProvider(object: text as NSString)] | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| private func openFeedbackReport(for entry: TranscriptionHistoryEntry) { | ||
| self.selectedReportEntry = entry | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.