diff --git a/Sources/Fluid/UI/CursorCopyToast.swift b/Sources/Fluid/UI/CursorCopyToast.swift new file mode 100644 index 00000000..9e7d98b1 --- /dev/null +++ b/Sources/Fluid/UI/CursorCopyToast.swift @@ -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? + 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) + } +} diff --git a/Sources/Fluid/UI/HistoryCopy.swift b/Sources/Fluid/UI/HistoryCopy.swift new file mode 100644 index 00000000..cec99d64 --- /dev/null +++ b/Sources/Fluid/UI/HistoryCopy.swift @@ -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) + } +} diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index b4aab1fa..97f98652 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -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? + @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,9 +226,20 @@ 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() + } + } + ) .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") } @@ -207,12 +247,14 @@ struct TranscriptionHistoryView: View { 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 }) + } + 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)] + } + private func openFeedbackReport(for entry: TranscriptionHistoryEntry) { self.selectedReportEntry = entry } diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 642718a9..55a1cdb5 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -80,6 +80,61 @@ final class DictationE2ETests: XCTestCase { XCTAssertNil(entry.clipboardText) } + // MARK: - HistoryCopy (copy-gesture text selection for the History list) + + private func makeHistoryEntry(raw: String, processed: String, aiProcessed: Bool) -> TranscriptionHistoryEntry { + TranscriptionHistoryEntry( + rawText: raw, + processedText: processed, + appName: "Notes", + windowTitle: "Draft", + wasAIProcessed: aiProcessed + ) + } + + func testHistoryCopyTextPrefersProcessedText() { + let entry = self.makeHistoryEntry(raw: " raw ", processed: " processed ", aiProcessed: true) + XCTAssertEqual(HistoryCopy.text(for: entry), "processed") + } + + func testHistoryCopyTextFallsBackToRawWhenProcessedEmpty() { + let entry = self.makeHistoryEntry(raw: " raw ", processed: " ", aiProcessed: false) + XCTAssertEqual(HistoryCopy.text(for: entry), "raw") + } + + func testHistoryCopyTextIsNilWhenBothEmpty() { + let entry = self.makeHistoryEntry(raw: " ", processed: " ", aiProcessed: false) + XCTAssertNil(HistoryCopy.text(for: entry)) + } + + func testHistoryNextIndexDownFromNilSelectsFirst() { + XCTAssertEqual(HistoryCopy.nextIndex(current: nil, count: 3, moveUp: false), 0) + } + + func testHistoryNextIndexUpFromNilSelectsFirst() { + XCTAssertEqual(HistoryCopy.nextIndex(current: nil, count: 3, moveUp: true), 0) + } + + func testHistoryNextIndexDownMovesToNext() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 0, count: 3, moveUp: false), 1) + } + + func testHistoryNextIndexUpMovesToPrevious() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 2, count: 3, moveUp: true), 1) + } + + func testHistoryNextIndexDownClampsAtEnd() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 2, count: 3, moveUp: false), 2) + } + + func testHistoryNextIndexUpClampsAtStart() { + XCTAssertEqual(HistoryCopy.nextIndex(current: 0, count: 3, moveUp: true), 0) + } + + func testHistoryNextIndexEmptyListIsNil() { + XCTAssertNil(HistoryCopy.nextIndex(current: nil, count: 0, moveUp: false)) + } + func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") XCTAssertNil(SettingsStore.TranscriptionStartSound.none.startSoundFileName)