Skip to content
Closed
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
51 changes: 51 additions & 0 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ final class AppController: NSObject, NSApplicationDelegate {

let store = ClipboardStore.shared
let monitor = ClipboardMonitor()
let pasteSequence = PasteSequence.shared

private var barController: BarWindowController?
private var statusItem: NSStatusItem?
private var settingsWindow: NSWindow?
private var pasteStackController: PasteStackWindowController?
private var keyMonitor: Any?

private(set) var previousApp: NSRunningApplication?
private(set) var lastActiveApp: NSRunningApplication?
private var pasteStackTargetApp: NSRunningApplication?

var suppressAutoHide = false

Expand All @@ -29,6 +32,7 @@ final class AppController: NSObject, NSApplicationDelegate {
monitor.start()

HotKeyCenter.shared.onTrigger = { [weak self] in self?.toggleBar() }
HotKeyCenter.shared.onSequenceTrigger = { [weak self] in self?.pasteNextInSequence() }
HotKeyCenter.shared.start()

setupStatusItem()
Expand Down Expand Up @@ -167,6 +171,53 @@ final class AppController: NSObject, NSApplicationDelegate {
hideBar()
}

func beginPasteSequence() {
pasteStackTargetApp = previousApp ?? lastActiveApp
pasteSequence.begin()
showPasteStack()
hideBar()

let target = pasteStackTargetApp
DispatchQueue.main.async {
target?.activate(options: [])
}
}

func showPasteStack() {
if pasteStackController == nil {
pasteStackController = PasteStackWindowController()
}
pasteStackController?.show()
}

func cancelPasteSequence() {
pasteSequence.cancel()
pasteStackController?.hide()
pasteStackTargetApp = nil
}

func capturePasteStackItem(_ item: ClipItem) {
_ = pasteSequence.addIfNeeded(item)
}

func pasteNextInSequence() {
guard pasteSequence.hasEntries else { return }

#if !MAS
guard !Settings.shared.pasteDirectly || PasteService.ensureAccessibility(prompt: true) else { return }
#endif

guard let entry = pasteSequence.next() else { return }
PasteService.paste(entry.item,
into: pasteStackTargetApp ?? previousApp,
monitor: monitor,
imageOverride: entry.imagePreview)
if !pasteSequence.hasEntries {
pasteStackController?.hide()
pasteStackTargetApp = nil
}
}

func showSettings() {
NSApp.activate(ignoringOtherApps: true)
if let win = settingsWindow {
Expand Down
41 changes: 33 additions & 8 deletions Sources/Pesty/Hotkey/HotKeyCenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ final class HotKeyCenter {
static let shared = HotKeyCenter()

var onTrigger: (() -> Void)?
var onSequenceTrigger: (() -> Void)?

private var hotKeyRef: EventHotKeyRef?
private var sequenceHotKeyRef: EventHotKeyRef?
private var handlerRef: EventHandlerRef?
private let signature: OSType = 0x50535459

Expand All @@ -22,25 +24,48 @@ final class HotKeyCenter {
guard handlerRef == nil else { return }
var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
eventKind: OSType(kEventHotKeyPressed))
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ -> OSStatus in
DispatchQueue.main.async { HotKeyCenter.shared.onTrigger?() }
InstallEventHandler(GetApplicationEventTarget(), { _, event, _ -> OSStatus in
var id = EventHotKeyID()
GetEventParameter(event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&id)
DispatchQueue.main.async {
if id.id == 2 {
HotKeyCenter.shared.onSequenceTrigger?()
} else {
HotKeyCenter.shared.onTrigger?()
}
}
return noErr
}, 1, &spec, nil, &handlerRef)
}

func reload() {
unregister()
let keyCode = UInt32(Settings.shared.hotkeyKeyCode)
let modifiers = UInt32(Settings.shared.hotkeyModifiers)
guard keyCode != 0 else { return }
let id = EventHotKeyID(signature: signature, id: 1)
hotKeyRef = register(keyCode: Settings.shared.hotkeyKeyCode,
modifiers: Settings.shared.hotkeyModifiers,
id: 1)
sequenceHotKeyRef = register(keyCode: Settings.shared.sequenceHotkeyKeyCode,
modifiers: Settings.shared.sequenceHotkeyModifiers,
id: 2)
}

private func register(keyCode: Int, modifiers: Int, id: UInt32) -> EventHotKeyRef? {
guard keyCode != 0 else { return nil }
let hotKeyID = EventHotKeyID(signature: signature, id: id)
var ref: EventHotKeyRef?
let status = RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &ref)
if status == noErr { hotKeyRef = ref }
let status = RegisterEventHotKey(UInt32(keyCode), UInt32(modifiers), hotKeyID,
GetApplicationEventTarget(), 0, &ref)
return status == noErr ? ref : nil
}

private func unregister() {
if let ref = hotKeyRef { UnregisterEventHotKey(ref); hotKeyRef = nil }
if let ref = sequenceHotKeyRef { UnregisterEventHotKey(ref); sequenceHotKeyRef = nil }
}

static func describe(keyCode: Int, modifiers: Int) -> String {
Expand Down
3 changes: 2 additions & 1 deletion Sources/Pesty/Monitor/ClipboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ final class ClipboardMonitor {
lastChangeCount = current
if current == suppressUntilChangeCount { return }
guard let item = makeItem() else { return }
ClipboardStore.shared.addCaptured(item)
let storedItem = ClipboardStore.shared.addCaptured(item)
AppController.shared.capturePasteStackItem(storedItem)
}

private func makeItem() -> ClipItem? {
Expand Down
11 changes: 7 additions & 4 deletions Sources/Pesty/Monitor/PasteService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import Carbon.HIToolbox
enum PasteService {

@discardableResult
static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int {
static func copy(_ item: ClipItem,
to pasteboard: NSPasteboard = .general,
imageOverride: NSImage? = nil) -> Int {
if item.type == .image {
guard let img = ClipboardStore.shared.loadImage(for: item) else {
guard let img = imageOverride ?? ClipboardStore.shared.loadImage(for: item) else {
return pasteboard.changeCount
}
pasteboard.clearContents()
Expand Down Expand Up @@ -38,8 +40,9 @@ enum PasteService {

static func paste(_ item: ClipItem,
into targetApp: NSRunningApplication?,
monitor: ClipboardMonitor) {
let change = copy(item)
monitor: ClipboardMonitor,
imageOverride: NSImage? = nil) {
let change = copy(item, imageOverride: imageOverride)
monitor.suppressUntilChangeCount = change
if Settings.shared.playSound { NSSound(named: "Pop")?.play() }

Expand Down
14 changes: 10 additions & 4 deletions Sources/Pesty/Settings/HotkeyRecorderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import AppKit
import Carbon.HIToolbox

struct HotkeyRecorderView: View {
@Bindable private var settings = Settings.shared
@Binding private var keyCode: Int
@Binding private var modifiers: Int
@State private var recording = false
@State private var monitor: Any?

init(keyCode: Binding<Int>, modifiers: Binding<Int>) {
_keyCode = keyCode
_modifiers = modifiers
}

var body: some View {
Button {
recording ? stop() : start()
} label: {
Text(recording ? "Press keys…" : settings.hotkeyDisplay)
Text(recording ? "Press keys…" : HotKeyCenter.describe(keyCode: keyCode, modifiers: modifiers))
.font(.system(size: 13, weight: .medium, design: .rounded))
.frame(minWidth: 90)
.padding(.horizontal, 12).padding(.vertical, 5)
Expand All @@ -34,8 +40,8 @@ struct HotkeyRecorderView: View {
if mods & (cmdKey | controlKey | optionKey) == 0 {
NSSound.beep(); return nil
}
settings.hotkeyKeyCode = Int(event.keyCode)
settings.hotkeyModifiers = mods
keyCode = Int(event.keyCode)
modifiers = mods
stop()
return nil
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/Pesty/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ final class Settings {
static let historyLimit = "historyLimit"
static let hotkeyKeyCode = "hotkeyKeyCode"
static let hotkeyModifiers = "hotkeyModifiers"
static let sequenceHotkeyKeyCode = "sequenceHotkeyKeyCode"
static let sequenceHotkeyModifiers = "sequenceHotkeyModifiers"
static let launchAtLogin = "launchAtLogin"
static let pasteDirectly = "pasteDirectly"
static let playSound = "playSound"
Expand Down Expand Up @@ -42,6 +44,16 @@ final class Settings {
d.set(hotkeyModifiers, forKey: Keys.hotkeyModifiers); HotKeyCenter.shared.reload() }
}

var sequenceHotkeyKeyCode: Int {
didSet { guard isLoaded else { return }
d.set(sequenceHotkeyKeyCode, forKey: Keys.sequenceHotkeyKeyCode); HotKeyCenter.shared.reload() }
}

var sequenceHotkeyModifiers: Int {
didSet { guard isLoaded else { return }
d.set(sequenceHotkeyModifiers, forKey: Keys.sequenceHotkeyModifiers); HotKeyCenter.shared.reload() }
}

var launchAtLogin: Bool {
didSet { guard isLoaded else { return }
d.set(launchAtLogin, forKey: Keys.launchAtLogin); LaunchAtLogin.set(enabled: launchAtLogin) }
Expand Down Expand Up @@ -81,6 +93,8 @@ final class Settings {
Keys.historyLimit: 500,
Keys.hotkeyKeyCode: kVK_ANSI_V,
Keys.hotkeyModifiers: cmdKey | shiftKey,
Keys.sequenceHotkeyKeyCode: kVK_ANSI_V,
Keys.sequenceHotkeyModifiers: cmdKey | optionKey,
Keys.launchAtLogin: false,
Keys.pasteDirectly: true,
Keys.playSound: false,
Expand All @@ -92,6 +106,8 @@ final class Settings {
historyLimit = d.integer(forKey: Keys.historyLimit)
hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode)
hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers)
sequenceHotkeyKeyCode = d.integer(forKey: Keys.sequenceHotkeyKeyCode)
sequenceHotkeyModifiers = d.integer(forKey: Keys.sequenceHotkeyModifiers)
launchAtLogin = d.bool(forKey: Keys.launchAtLogin)
pasteDirectly = d.bool(forKey: Keys.pasteDirectly)
playSound = d.bool(forKey: Keys.playSound)
Expand All @@ -105,4 +121,8 @@ final class Settings {
var hotkeyDisplay: String {
HotKeyCenter.describe(keyCode: hotkeyKeyCode, modifiers: hotkeyModifiers)
}

var sequenceHotkeyDisplay: String {
HotKeyCenter.describe(keyCode: sequenceHotkeyKeyCode, modifiers: sequenceHotkeyModifiers)
}
}
15 changes: 14 additions & 1 deletion Sources/Pesty/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ private struct GeneralSettings: View {
var body: some View {
Form {
Section("Activation") {
LabeledContent("Show Pesty") { HotkeyRecorderView() }
LabeledContent("Show Pesty") {
HotkeyRecorderView(keyCode: $settings.hotkeyKeyCode,
modifiers: $settings.hotkeyModifiers)
}
Stepper(value: $settings.historyLimit, in: 50...5000, step: 50) {
LabeledContent("History limit", value: "\(settings.historyLimit) items")
}
}

Section("Paste Stack") {
LabeledContent("Paste next clip") {
HotkeyRecorderView(keyCode: $settings.sequenceHotkeyKeyCode,
modifiers: $settings.sequenceHotkeyModifiers)
}
Text("Start a Paste Stack from the strip, then copy clips in another app. Use this shortcut to paste each clip in order.")
.font(.caption)
.foregroundStyle(.secondary)
}

Section("Behavior") {
#if !MAS
Toggle("Paste directly into the active app", isOn: $settings.pasteDirectly)
Expand Down
6 changes: 4 additions & 2 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,24 @@ final class ClipboardStore {
return visibleItems.first(where: { $0.id == id })
}

func addCaptured(_ item: ClipItem) {
@discardableResult
func addCaptured(_ item: ClipItem) -> ClipItem {
if let idx = history.firstIndex(where: { $0.sameContent(as: item) }) {
if item.imageFileName != history[idx].imageFileName { deleteImageFile(item) }
var existing = history.remove(at: idx)
existing.createdAt = item.createdAt
history.insert(existing, at: 0)
if source == .history && searchText.isEmpty { selectedID = existing.id }
scheduleSave()
return
return existing
}
history.insert(item, at: 0)
trimHistory()
if source == .history && searchText.isEmpty {
selectedID = item.id
}
scheduleSave()
return item
}

func applyHistoryLimit() { trimHistory(); scheduleSave() }
Expand Down
57 changes: 57 additions & 0 deletions Sources/Pesty/Store/PasteSequence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import AppKit
import Observation

struct PasteStackEntry: Identifiable {
let id = UUID()
let item: ClipItem
/// Holds an in-memory image while a Stack is active, even if the image is
/// later pruned from clipboard history.
let imagePreview: NSImage?

init(item: ClipItem, imagePreview: NSImage? = nil) {
self.item = item
self.imagePreview = imagePreview
}
}

@Observable
@MainActor
final class PasteSequence {
static let shared = PasteSequence()

private(set) var entries: [PasteStackEntry] = []
private(set) var isCollecting = false

var hasEntries: Bool { !entries.isEmpty }
var pendingCount: Int { entries.count }

private init() {}

func begin() {
entries.removeAll()
isCollecting = true
}

@discardableResult
func addIfNeeded(_ item: ClipItem) -> Bool {
guard isCollecting,
!entries.contains(where: { $0.item.id == item.id }) else { return false }
let imagePreview = item.type == .image ? ClipboardStore.shared.loadImage(for: item) : nil
entries.append(PasteStackEntry(item: item, imagePreview: imagePreview))
return true
}

func next() -> PasteStackEntry? {
guard !entries.isEmpty else {
isCollecting = false
return nil
}
isCollecting = false
return entries.removeFirst()
}

func cancel() {
entries.removeAll()
isCollecting = false
}
}
Loading