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
13 changes: 13 additions & 0 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {

let store = ClipboardStore.shared
let monitor = ClipboardMonitor()
private let copyToast = CopyToastController()

private var barController: BarWindowController?
private var statusItem: NSStatusItem?
Expand Down Expand Up @@ -324,9 +325,19 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
}

func copyItem(_ item: ClipItem) {
let previousChange = NSPasteboard.general.changeCount
let change = PasteService.copy(item)
monitor.suppressUntilChangeCount = change
if change != previousChange {
store.promoteCopiedItem(item)
}
hideBar()
copyToast.show()
}

func copySelected() {
guard let item = store.selectedItem else { return }
copyItem(item)
}

var pasteMenuTitle: String {
Expand Down Expand Up @@ -550,6 +561,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
case kVK_ForwardDelete:
deleteEffectiveSelection()
return nil
case kVK_ANSI_C:
if cmd { copySelected(); return nil }
default:
break
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ final class ClipboardStore {
scheduleSave()
}

/// A Copy from the Paste Bar is an intentional use of an existing clip.
/// Promote it explicitly because the clipboard monitor correctly ignores
/// Pesty's own pasteboard writes, so a plain re-copy would otherwise leave
/// the clip sitting wherever it already was in history.
func promoteCopiedItem(_ item: ClipItem, at date: Date = .now) {
var copied = item
copied.createdAt = date
addCaptured(copied)
}

func applyRetentionPolicy() { trimHistory(); scheduleSave() }

func retentionRemovalCount(mode: HistoryRetentionMode, limit: Int, days: Int) -> Int {
Expand Down
74 changes: 74 additions & 0 deletions Sources/Pesty/UI/CopyToastController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import AppKit
import SwiftUI

@MainActor
final class CopyToastController {
private let panel: NSPanel
private var dismissWorkItem: DispatchWorkItem?

init() {
panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 236, height: 48),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .floating
panel.ignoresMouseEvents = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
panel.contentView = NSHostingView(rootView: CopyToastView())
}

func show() {
dismissWorkItem?.cancel()
let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) })
?? NSScreen.main
?? NSScreen.screens.first
guard let screen else { return }

let frame = screen.visibleFrame
panel.setFrameOrigin(NSPoint(x: frame.midX - panel.frame.width / 2, y: frame.minY + 34))
panel.alphaValue = 0
panel.orderFrontRegardless()

NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
panel.animator().alphaValue = 1
}

let dismiss = DispatchWorkItem { [weak self] in self?.dismiss() }
dismissWorkItem = dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 1.25, execute: dismiss)
}

private func dismiss() {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.18
panel.animator().alphaValue = 0
}, completionHandler: { [weak panel] in
panel?.orderOut(nil)
})
}
}

private struct CopyToastView: View {
var body: some View {
HStack(spacing: 9) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Theme.selection)
Text("Copied to Clipboard")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
}
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.regularMaterial, in: Capsule(style: .continuous))
.overlay {
Capsule(style: .continuous)
.strokeBorder(.white.opacity(0.28))
}
}
}
Loading