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
118 changes: 113 additions & 5 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ final class AppController: NSObject, NSApplicationDelegate {
private var barController: BarWindowController?
private var statusItem: NSStatusItem?
private var settingsWindow: NSWindow?
private var previewWindow: NSWindow?
private var previewedItemID: UUID?
private var keyMonitor: Any?

private(set) var previousApp: NSRunningApplication?
Expand Down Expand Up @@ -131,8 +133,9 @@ final class AppController: NSObject, NSApplicationDelegate {

func showBar() {
let front = NSWorkspace.shared.frontmostApplication
if front?.bundleIdentifier != Bundle.main.bundleIdentifier {
if let front, !isPesty(front) {
previousApp = front
lastActiveApp = front
}
store.searchText = ""
store.source = .history
Expand All @@ -152,13 +155,30 @@ final class AppController: NSObject, NSApplicationDelegate {

func pasteSelected() {
guard let item = store.selectedItem else { return }
hideBar()
PasteService.paste(item, into: previousApp, monitor: monitor)
pasteItem(item)
}

/// The app that will receive a paste after the floating Pesty panel closes.
/// `previousApp` is captured before the panel activates, while
/// `lastActiveApp` covers menu-bar and reopen paths where it is unavailable.
private var pasteTarget: NSRunningApplication? {
[lastActiveApp, previousApp, NSWorkspace.shared.frontmostApplication]
.compactMap { $0 }
.first { !$0.isTerminated && !isPesty($0) }
}

var pasteMenuTitle: String {
guard let name = pasteTarget?.localizedName,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "Paste"
}
return "Paste to \(name)"
}

func pasteItem(_ item: ClipItem) {
func pasteItem(_ item: ClipItem, asPlainText: Bool = false) {
let target = pasteTarget
hideBar()
PasteService.paste(item, into: previousApp, monitor: monitor)
PasteService.paste(item, into: target, monitor: monitor, asPlainText: asPlainText)
}

func copyItem(_ item: ClipItem) {
Expand All @@ -167,6 +187,89 @@ final class AppController: NSObject, NSApplicationDelegate {
hideBar()
}

func editItem(_ item: ClipItem, launchWritingTools: Bool = false) {
suppressAutoHide = true
// The bar's local monitor normally consumes typeable keys for search
// and navigation. Suspend it while the native editor owns first
// responder so typing, Delete, Return, and Writing Tools all reach
// the NSTextView instead.
let resumeBarKeys = barController?.window?.isVisible == true
if resumeBarKeys { stopKeyMonitor() }
defer {
suppressAutoHide = false
if resumeBarKeys { startKeyMonitor() }
}

guard let edit = ClipEditor.run(for: item, launchWritingTools: launchWritingTools) else { return }

let changed: Bool
switch edit {
case let .text(text, richTextData):
changed = store.updateTextContent(text, richTextData: richTextData, for: item)
case let .color(hex):
changed = store.updateColorContent(hex, for: item)
}
guard changed, let updatedItem = store.item(withID: item.id) else { return }

// The edited content becomes the live clipboard as well. Suppress the
// monitor so this is an in-place change rather than a duplicate entry.
let change = PasteService.copy(updatedItem)
monitor.suppressUntilChangeCount = change

if previewedItemID == item.id { showPreview(for: updatedItem) }
}

func showPreview(for item: ClipItem) {
let host = NSHostingController(rootView: ClipPreviewView(item: item))
let title = "Preview — \(item.displayTitle)"
previewedItemID = item.id

if let window = previewWindow {
window.title = title
window.contentViewController = host
window.makeKeyAndOrderFront(nil)
return
}

let previewWindow = NSWindow(contentViewController: host)
previewWindow.title = title
previewWindow.styleMask = [.titled, .closable, .miniaturizable, .resizable]
previewWindow.setContentSize(NSSize(width: 540, height: 400))
previewWindow.minSize = NSSize(width: 400, height: 260)
previewWindow.isReleasedWhenClosed = false
previewWindow.center()
self.previewWindow = previewWindow
previewWindow.makeKeyAndOrderFront(nil)
}

func showSharePicker(for item: ClipItem) {
let items = shareItems(for: item)
guard !items.isEmpty,
let view = barController?.window?.contentView ?? NSApp.keyWindow?.contentView else { return }

let picker = NSSharingServicePicker(items: items)
let anchor = NSRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1)
picker.show(relativeTo: anchor, of: view, preferredEdge: .maxY)
}

private func shareItems(for item: ClipItem) -> [Any] {
switch item.type {
case .image:
return store.loadImage(for: item).map { [$0] } ?? []
case .file:
let urls = item.fileURLs.compactMap(URL.init(string:))
return urls.isEmpty ? item.plainText.map { [$0 as NSString] } ?? [] : urls
case .color, .text, .richText, .link:
return item.plainText.map { [$0 as NSString] } ?? []
}
}

private func isPesty(_ app: NSRunningApplication) -> Bool {
if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { return true }
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
return app.bundleIdentifier == bundleID
}

func showSettings() {
NSApp.activate(ignoringOtherApps: true)
if let win = settingsWindow {
Expand Down Expand Up @@ -198,6 +301,11 @@ final class AppController: NSObject, NSApplicationDelegate {
}

private func handleKey(_ event: NSEvent) -> NSEvent? {
// Events belonging to a native context menu, editor, or Settings
// window must stay with their own responder chain. The bar monitor is
// only responsible for keys delivered to the Paste Bar panel itself.
guard event.window === barController?.window else { return event }

let code = Int(event.keyCode)
let flags = event.modifierFlags
let cmd = flags.contains(.command)
Expand Down
18 changes: 18 additions & 0 deletions Sources/Pesty/Models/ClipItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ struct ClipItem: Identifiable, Codable, Equatable {

var charCount: Int { text?.count ?? 0 }

/// The representation used when a clip is explicitly pasted as plain text.
/// Images intentionally do not have one: converting an image to an
/// arbitrary description would be surprising and lossy.
var plainText: String? {
switch type {
case .image:
return nil
case .color:
return colorHex
case .file:
if let text, !text.isEmpty { return text }
let paths = fileURLs.map { URL(string: $0)?.path ?? $0 }
return paths.isEmpty ? nil : paths.joined(separator: "\n")
case .text, .richText, .link:
return text
}
}

var displayTitle: String {
if let t = customTitle, !t.isEmpty { return t }
switch type {
Expand Down
16 changes: 13 additions & 3 deletions Sources/Pesty/Monitor/PasteService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import Carbon.HIToolbox
enum PasteService {

@discardableResult
static func copy(_ item: ClipItem, to pasteboard: NSPasteboard = .general) -> Int {
static func copy(_ item: ClipItem,
asPlainText: Bool = false,
to pasteboard: NSPasteboard = .general) -> Int {
if asPlainText {
guard let text = item.plainText else { return pasteboard.changeCount }
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
return pasteboard.changeCount
}

if item.type == .image {
guard let img = ClipboardStore.shared.loadImage(for: item) else {
return pasteboard.changeCount
Expand Down Expand Up @@ -38,8 +47,9 @@ enum PasteService {

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

Expand Down
81 changes: 81 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,87 @@ final class ClipboardStore {
scheduleSave()
}

/// Finds the current version of a clip after an edit. Pinboard items retain
/// the history item's identity, so changing a clip can update every saved
/// copy without matching unrelated clips that happen to have the same text.
func item(withID id: UUID) -> ClipItem? {
if let item = history.first(where: { $0.id == id }) { return item }
return pinboards.lazy.flatMap(\.items).first(where: { $0.id == id })
}

/// Updates the saved payload of a text-like clip while retaining its
/// identity, source attribution, creation date, and optional card title.
@discardableResult
func updateTextContent(_ text: String, richTextData: Data? = nil, for item: ClipItem) -> Bool {
guard [.text, .richText, .link].contains(item.type),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }

let type: ClipType = richTextData != nil ? .richText : (isWebLink(text) ? .link : .text)
return updateContent(for: item) { existing in
var updated = existing
updated.type = type
updated.text = text
updated.rtfData = richTextData
updated.colorHex = nil
return updated
}
}

/// Updates a color clip with a normalized sRGB hex value.
@discardableResult
func updateColorContent(_ hex: String, for item: ClipItem) -> Bool {
guard item.type == .color, let color = NSColor(hex: hex) else { return false }
let normalizedHex = color.hexString
return updateContent(for: item) { existing in
var updated = existing
updated.type = .color
updated.text = nil
updated.rtfData = nil
updated.colorHex = normalizedHex
return updated
}
}

@discardableResult
private func updateContent(for item: ClipItem,
transform: (ClipItem) -> ClipItem) -> Bool {
var changed = false

if let i = history.firstIndex(where: { $0.id == item.id }) {
let updated = transform(history[i])
if updated != history[i] {
history[i] = updated
changed = true
}
}

for boardIndex in pinboards.indices {
for itemIndex in pinboards[boardIndex].items.indices
where pinboards[boardIndex].items[itemIndex].id == item.id {
let updated = transform(pinboards[boardIndex].items[itemIndex])
if updated != pinboards[boardIndex].items[itemIndex] {
pinboards[boardIndex].items[itemIndex] = updated
changed = true
}
}
}

guard changed else { return false }
if selectedItem == nil { selectFirst() }
scheduleSave()
return true
}

private func isWebLink(_ text: String) -> Bool {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains(" "), !value.contains("\n"),
let url = URL(string: value),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
url.host != nil else { return false }
return true
}

func selectFirst() { selectedID = visibleItems.first?.id }

func moveSelection(by delta: Int) {
Expand Down
Loading
Loading