Skip to content
Draft
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
177 changes: 172 additions & 5 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 @@ -129,14 +133,15 @@ final class AppController: NSObject, NSApplicationDelegate {
}
}

func showBar() {
func showBar(source requestedSource: BarSource? = nil) {
let front = NSWorkspace.shared.frontmostApplication
if front?.bundleIdentifier != Bundle.main.bundleIdentifier {
previousApp = front
}
store.searchText = ""
store.source = .history
store.selectFirst()
store.source = requestedSource ?? .history
store.applyHistoryPolicy()
if store.source != .pasteStack { store.selectFirst() }

if barController == nil {
barController = BarWindowController()
Expand Down Expand Up @@ -167,6 +172,143 @@ 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 hidePasteStack() {
pasteStackController?.hide()
}

func showPasteStackTab(stackID: UUID? = nil) {
store.searchText = ""
store.source = .pasteStack
if let stackID {
pasteSequence.selectStack(stackID)
} else {
pasteSequence.selectFirst()
}
pasteStackController?.hide()
if barController?.window?.isVisible != true {
showBar(source: .pasteStack)
}
}

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

func newPasteStack() {
pasteStackTargetApp = previousApp ?? lastActiveApp
pasteSequence.newStack()
showPasteStack()
hideBar()

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

func pausePasteSequence() {
pasteSequence.pause()
}

func clearPasteStack() {
cancelPasteSequence()
}

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

func removePasteStackEntry(_ entry: PasteStackEntry) {
pasteSequence.remove(entry)
}

func reAddPasteStackEntry(_ entry: PasteStackEntry) {
pasteSequence.reAdd(entry)
}

func resetPasteStackProgress() {
pasteSequence.resetProgress()
}

/// Saves the deck in its displayed paste order so a temporary Paste Stack
/// can become a durable Pinboard.
func savePasteStack() {
guard pasteSequence.hasEntries,
let name = TextPrompt.run(title: "Save Paste Stack",
message: "Save the current stack as a pinboard named:",
defaultValue: "Paste Stack") else { return }

let board = store.addPinboard(name: name)
for entry in pasteSequence.displayEntries.reversed() {
store.saveToPinboard(entry.item, boardID: board.id)
}
}

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

guard let entry = pasteSequence.next() else { return }
performPasteStackEntry(entry)
}

func pasteStackEntry(_ entry: PasteStackEntry) {
guard let entry = pasteSequence.next(entryID: entry.id) else { return }
performPasteStackEntry(entry)
}

func pasteSelectedStackEntry() {
guard let entry = pasteSequence.selectedEntry else { return }
pasteStackEntry(entry)
}

private func performPasteStackEntry(_ entry: PasteStackEntry) {
let target = pasteTargetApp()
hideBar()
PasteService.paste(entry.item,
into: target,
monitor: monitor,
imageOverride: entry.imagePreview)
}

/// Resolve the destination when the user chooses to paste, rather than
/// holding the app that was active when the Stack was first opened.
private func pasteTargetApp() -> NSRunningApplication? {
if let frontmost = NSWorkspace.shared.frontmostApplication,
frontmost.bundleIdentifier != Bundle.main.bundleIdentifier {
previousApp = frontmost
return frontmost
}
if let lastActiveApp,
lastActiveApp.bundleIdentifier != Bundle.main.bundleIdentifier,
!lastActiveApp.isTerminated {
return lastActiveApp
}
return pasteStackTargetApp ?? previousApp
}

func showSettings() {
NSApp.activate(ignoringOtherApps: true)
if let win = settingsWindow {
Expand Down Expand Up @@ -204,7 +346,11 @@ final class AppController: NSObject, NSApplicationDelegate {
let ctrl = flags.contains(.control)
let opt = flags.contains(.option)

if cmd, let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n) {
if store.source != .pasteStack,
cmd,
let chars = event.charactersIgnoringModifiers,
let n = Int(chars),
(1...9).contains(n) {
let items = store.visibleItems
if n <= items.count { pasteItem(items[n - 1]) }
return nil
Expand All @@ -216,25 +362,46 @@ final class AppController: NSObject, NSApplicationDelegate {
else { hideBar() }
return nil
case kVK_Return, kVK_ANSI_KeypadEnter:
if store.source == .pasteStack {
pasteSelectedStackEntry()
return nil
}
pasteSelected(); return nil
case kVK_LeftArrow, kVK_UpArrow:
if store.source == .pasteStack {
pasteSequence.moveSelection(by: -1)
return nil
}
store.moveSelection(by: -1); return nil
case kVK_RightArrow, kVK_DownArrow:
if store.source == .pasteStack {
pasteSequence.moveSelection(by: 1)
return nil
}
store.moveSelection(by: 1); return nil
case kVK_Delete:
if store.source == .pasteStack, let entry = pasteSequence.selectedEntry {
removePasteStackEntry(entry)
return nil
}
if cmd, let sel = store.selectedItem { store.delete(sel); return nil }
if !store.searchText.isEmpty {
store.searchText.removeLast(); store.selectFirst(); return nil
}
return nil
case kVK_ForwardDelete:
if store.source == .pasteStack, let entry = pasteSequence.selectedEntry {
removePasteStackEntry(entry)
return nil
}
if let sel = store.selectedItem { store.delete(sel) }
return nil
default:
break
}

if !cmd && !ctrl && !opt,
if store.source != .pasteStack,
!cmd && !ctrl && !opt,
let chars = event.characters, chars.count == 1,
let scalar = chars.unicodeScalars.first,
scalar.value >= 32, scalar.value != 127 {
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
Loading