Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions Sources/Fluid/UI/CursorCopyToast.swift
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)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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)
}
}
25 changes: 25 additions & 0 deletions Sources/Fluid/UI/HistoryCopy.swift
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)
}
}
142 changes: 128 additions & 14 deletions Sources/Fluid/UI/TranscriptionHistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep shortcut copy text consistent with Copy controls

This new double-click path copies HistoryCopy.text(for:), which trims whitespace and falls back to raw text, while the existing Copy button and context-menu Copy in this view still call copyToClipboard(entry.processedText) directly. For entries with leading/trailing whitespace, or restored/legacy entries whose processed text is empty but raw text exists, double-click/⌘C now place different content on the pasteboard than the visible Copy controls. Please route the button/menu through the same helper, or otherwise make all History copy paths use the same source.

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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
}
Comment thread
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)]
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

private func openFeedbackReport(for entry: TranscriptionHistoryEntry) {
self.selectedReportEntry = entry
}
Expand Down
Loading
Loading