From 9a10e826b836182807ce46d7d5234af0bdf3baa2 Mon Sep 17 00:00:00 2001 From: momenbasel Date: Wed, 12 Aug 2026 10:06:26 +0300 Subject: [PATCH] Add full clip context menu with editor, preview, and sharing The context menu now offers Paste to , Paste as Plain Text, Copy, Edit, Writing Tools, Rename, a Pin submenu with colored pinboard dots, Preview, Share, and Delete. Editing opens a native rich-text editor panel with formatting controls, live stats, undo, find, and Writing Tools; colors get a color-well editor. Edits can no longer make CloudKit delete the clip that was just edited: updateContent bumps createdAt and removes same-content duplicates in the same container as an explicit, deliberate delete, so the edited record is always the newest copy of its content key and remote dedupe can never drop it into a reconciliation delete. Edits update the existing record in place - no delete and recreate, no dropped fields - and pinboard copies with their own UUIDs are left untouched while legacy shared-id copies stay consistent. A minimal main menu is installed at launch so the editor's Cmd-Z/X/C/ V/A/F shortcuts, Cmd-Q, and Cmd-W actually work; the app stays an accessory so no menu bar appears. Save in the editor is bound to Cmd-Return since Return types a newline, Rename pre-fills the custom title rather than baking in the auto-generated one, the preview window clears its tracked item via a window delegate when closed, and editing no longer silently overwrites the live system clipboard. Supersedes #42. Builds on the key-monitor scoping and pasteTarget infrastructure from #66. Co-authored-by: Alvie Stoddard Co-authored-by: Cursor --- Sources/Pesty/AppController.swift | 130 ++++++- Sources/Pesty/Store/ClipboardStore.swift | 86 +++++ Sources/Pesty/UI/ClipCardView.swift | 115 +++++- Sources/Pesty/UI/ClipEditor.swift | 471 +++++++++++++++++++++++ Sources/Pesty/UI/ClipPreviewView.swift | 88 +++++ 5 files changed, 873 insertions(+), 17 deletions(-) create mode 100644 Sources/Pesty/UI/ClipEditor.swift create mode 100644 Sources/Pesty/UI/ClipPreviewView.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 072c0e4..6d3f395 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -3,7 +3,7 @@ import SwiftUI import Carbon.HIToolbox @MainActor -final class AppController: NSObject, NSApplicationDelegate { +final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { static let shared = AppController() let store = ClipboardStore.shared @@ -13,6 +13,8 @@ final class AppController: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var pauseMenuItem: NSMenuItem? private var settingsWindow: NSWindow? + private var previewWindow: NSWindow? + private var previewedItemID: UUID? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? @@ -28,6 +30,7 @@ final class AppController: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + installMainMenu() NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(appActivated(_:)), @@ -83,6 +86,12 @@ final class AppController: NSObject, NSApplicationDelegate { store.saveNow() } + func windowWillClose(_ notification: Notification) { + if notification.object as? NSWindow === previewWindow { + previewedItemID = nil + } + } + /// Reopening from Finder, Spotlight, or the Dock surfaces the app. /// /// The escape hatch comes first: Pesty is an accessory app, so with the status @@ -110,6 +119,49 @@ final class AppController: NSObject, NSApplicationDelegate { return true } + private func installMainMenu() { + let main = NSMenu() + + let appItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About Pesty", action: #selector(menuAbout), keyEquivalent: "").target = self + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Settings…", action: #selector(menuSettings), keyEquivalent: ",").target = self + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Hide Pesty", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + appMenu.addItem(withTitle: "Quit Pesty", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + main.addItem(appItem) + + let editItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z") + redo.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editMenu.addItem(.separator()) + let find = editMenu.addItem(withTitle: "Find…", + action: #selector(NSTextView.performTextFinderAction(_:)), + keyEquivalent: "f") + find.tag = NSTextFinder.Action.showFindInterface.rawValue + editItem.submenu = editMenu + main.addItem(editItem) + + let windowItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowItem.submenu = windowMenu + main.addItem(windowItem) + NSApp.windowsMenu = windowMenu + + NSApp.mainMenu = main + } + private func setupStatusItem() { guard statusItem == nil else { return } let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -277,6 +329,82 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + var pasteMenuTitle: String { + guard let name = pasteTarget?.localizedName, + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "Paste" + } + return "Paste to \(name)" + } + + func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { + suppressAutoHide = true + defer { suppressAutoHide = false } + + 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 updated = store.item(withID: item.id) else { return } + if previewedItemID == item.id { showPreview(for: updated) } + } + + func showPreview(for item: ClipItem) { + suppressAutoHide = true + defer { suppressAutoHide = false } + NSApp.activate(ignoringOtherApps: true) + + 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 window = NSWindow(contentViewController: host) + window.title = title + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(NSSize(width: 540, height: 400)) + window.minSize = NSSize(width: 400, height: 260) + window.isReleasedWhenClosed = false + window.delegate = self + window.center() + previewWindow = window + window.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 } + suppressAutoHide = true + defer { suppressAutoHide = false } + 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:)).filter(\.isFileURL) + return urls.isEmpty ? (item.plainText.map { [$0 as NSString] } ?? []) : urls + case .color, .text, .richText, .link: + return item.plainText.map { [$0 as NSString] } ?? [] + } + } + func deleteEffectiveSelection() { let selection = store.effectiveSelectionIDs let targets = store.visibleItems.filter { selection.contains($0.id) } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 264becb..c0bcd40 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -279,6 +279,92 @@ final class ClipboardStore { scheduleSave() } + 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 }) + } + + @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(of: item) { existing in + var updated = existing + updated.type = type + updated.text = text + updated.rtfData = richTextData + updated.colorHex = nil + return updated + } + } + + @discardableResult + func updateColorContent(_ hex: String, for item: ClipItem) -> Bool { + guard item.type == .color, let color = NSColor(hex: hex) else { return false } + let normalized = color.hexString + return updateContent(of: item) { existing in + var updated = existing + updated.type = .color + updated.text = nil + updated.rtfData = nil + updated.colorHex = normalized + return updated + } + } + + private func updateContent(of item: ClipItem, transform: (ClipItem) -> ClipItem) -> Bool { + var changed = false + let now = Date() + + if let i = history.firstIndex(where: { $0.id == item.id }) { + var updated = transform(history[i]) + if updated != history[i] { + updated.createdAt = now + history.remove(at: i) + removeContentDuplicates(of: updated, in: &history) + history.insert(updated, at: 0) + changed = true + } + } + + for b in pinboards.indices { + guard let i = pinboards[b].items.firstIndex(where: { $0.id == item.id }) else { continue } + var updated = transform(pinboards[b].items[i]) + if updated != pinboards[b].items[i] { + updated.createdAt = now + pinboards[b].items[i] = updated + removeContentDuplicates(of: updated, in: &pinboards[b].items) + changed = true + } + } + + guard changed else { return false } + retentionPrunedRecordNames.remove(item.id.uuidString) + if selectedItem == nil { selectFirst() } + reconcileMultiSelection() + scheduleSave() + return true + } + + private func removeContentDuplicates(of item: ClipItem, in items: inout [ClipItem]) { + let key = contentKey(item) + let duplicates = items.filter { $0.id != item.id && contentKey($0) == key } + guard !duplicates.isEmpty else { return } + items.removeAll { $0.id != item.id && contentKey($0) == key } + for duplicate in duplicates { deleteImageFile(duplicate) } + } + + 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 setTitle(_ title: String, for item: ClipItem) { if let i = history.firstIndex(where: { $0.id == item.id }) { history[i].customTitle = title } for b in pinboards.indices { diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 7e40e5a..a8b4725 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -192,31 +193,78 @@ struct ClipCardView: View { @ViewBuilder private var menu: some View { - Button("Paste") { AppController.shared.pasteItem(item) } - Button("Copy") { AppController.shared.copyItem(item) } + Button { AppController.shared.pasteItem(item) } label: { + Label(AppController.shared.pasteMenuTitle, systemImage: "doc.on.clipboard") + } + + Button { AppController.shared.pasteItem(item, asPlainText: true) } label: { + Label("Paste as Plain Text", systemImage: "text.alignleft") + } + .disabled(item.plainText == nil) + + Button { AppController.shared.copyItem(item) } label: { + Label("Copy", systemImage: "doc.on.doc") + } + Divider() - if !store.pinboards.isEmpty { - Menu("Save to Pinboard") { + + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") + } + .disabled(!isEditable) + + if writingToolsAvailable { + Button { AppController.shared.editItem(item, launchWritingTools: true) } label: { + Label("Writing Tools", systemImage: "pencil.and.scribble") + } + } + + Button { renameItem() } label: { + Label("Rename…", systemImage: "pencil.line") + } + + Divider() + + Menu { + if store.pinboards.isEmpty { + Button("No Pinboards Yet") {} + .disabled(true) + } else { ForEach(store.pinboards) { b in - Button(b.name) { store.saveToPinboard(item, boardID: b.id) } + Button { store.saveToPinboard(item, boardID: b.id) } label: { + Label { + Text(b.name) + } icon: { + Image(nsImage: Self.pinboardMenuIcon(color: NSColor(b.color))) + .renderingMode(.original) + } + } } } - } - Button("Save to New Pinboard…") { - if let name = TextPrompt.run(title: "New Pinboard", message: "Name") { - let b = store.addPinboard(name: name) - store.saveToPinboard(item, boardID: b.id) + Divider() + Button { pinToNewBoard() } label: { + Label("Create Pinboard…", systemImage: "plus") } + } label: { + Label("Pin", systemImage: "pin") } - Button("Edit Title…") { - if let t = TextPrompt.run(title: "Edit Title", message: "Card title", - defaultValue: item.customTitle ?? "") { - store.setTitle(t, for: item) - } + + Divider() + + Button { AppController.shared.showPreview(for: item) } label: { + Label("Preview", systemImage: "eye") } + + Button { AppController.shared.showSharePicker(for: item) } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Divider() - Button(deleteMenuTitle, role: .destructive) { + + Button(role: .destructive) { AppController.shared.deleteSelection(containing: item) + } label: { + Label(deleteMenuTitle, systemImage: "trash") } } @@ -224,4 +272,39 @@ struct ClipCardView: View { let count = store.multiSelectedIDs.contains(item.id) ? store.multiSelectedIDs.count : 1 return count > 1 ? "Delete \(count) Clips" : "Delete" } + + private var isEditable: Bool { + [.text, .richText, .link, .color].contains(item.type) + } + + private var writingToolsAvailable: Bool { + guard [.text, .richText, .link].contains(item.type) else { return false } + guard #available(macOS 15.2, *) else { return false } + return NSWritingToolsCoordinator.isWritingToolsAvailable + } + + private func renameItem() { + if let title = TextPrompt.run(title: "Rename", message: "Card title", + defaultValue: item.customTitle ?? "") { + store.setTitle(title, for: item) + } + } + + private func pinToNewBoard() { + if let name = TextPrompt.run(title: "Create Pinboard", message: "Name") { + let board = store.addPinboard(name: name) + store.saveToPinboard(item, boardID: board.id) + } + } + + private static func pinboardMenuIcon(color: NSColor) -> NSImage { + let size = NSSize(width: 12, height: 12) + let image = NSImage(size: size, flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill() + return true + } + image.isTemplate = false + return image + } } diff --git a/Sources/Pesty/UI/ClipEditor.swift b/Sources/Pesty/UI/ClipEditor.swift new file mode 100644 index 0000000..b04bfe4 --- /dev/null +++ b/Sources/Pesty/UI/ClipEditor.swift @@ -0,0 +1,471 @@ +import AppKit + +@MainActor +enum ClipEditor { + enum Edit { + case text(String, richTextData: Data?) + case color(String) + } + + static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? { + NSApp.activate(ignoringOtherApps: true) + switch item.type { + case .text, .richText, .link: + return TextClipEditorController(item: item, + launchWritingTools: launchWritingTools).run() + case .color: + return editColor(item) + case .image, .file: + showUnsupportedEditor(for: item) + return nil + } + } + + private static func editColor(_ item: ClipItem) -> Edit? { + let alert = NSAlert() + alert.messageText = "Edit Color" + alert.informativeText = "Choose the color stored in this clip." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let color = item.colorHex.flatMap(NSColor.init(hex:)) ?? .black + let accessory = ColorEditorAccessoryView(color: color) + alert.accessoryView = accessory + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + return .color(accessory.selectedHex) + } + + private static func showUnsupportedEditor(for item: ClipItem) { + let alert = NSAlert() + alert.messageText = "This clip can't be edited" + alert.informativeText = "Pesty can edit text, rich text, links, and colors. \(item.type.label) clips are kept as-is." + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +@MainActor +private final class TextClipEditorController: NSObject, NSTextViewDelegate, NSWindowDelegate { + private let item: ClipItem + private let launchWritingTools: Bool + private let panel: NSPanel + private let textView = NSTextView() + private let saveButton = NSButton() + private let statsLabel = NSTextField(labelWithString: "") + private var result: ClipEditor.Edit? + private var appliedRichFormatting = false + + init(item: ClipItem, launchWritingTools: Bool) { + self.item = item + self.launchWritingTools = launchWritingTools + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 560), + styleMask: [.titled, .closable, .resizable, .utilityWindow, .fullSizeContentView], + backing: .buffered, + defer: false + ) + super.init() + configurePanel() + configureEditor() + buildInterface() + loadInitialContent() + updateStats() + } + + func run() -> ClipEditor.Edit? { + NSApp.activate(ignoringOtherApps: true) + panel.center() + panel.makeKeyAndOrderFront(nil) + panel.makeFirstResponder(textView) + + if launchWritingTools { + DispatchQueue.main.async { self.showWritingTools() } + } + + NSApp.runModal(for: panel) + panel.orderOut(nil) + return result + } + + func textDidChange(_ notification: Notification) { + updateStats() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + finish(with: nil) + return false + } + + private func configurePanel() { + panel.delegate = self + panel.title = "Edit \(item.type.label)" + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.isReleasedWhenClosed = false + panel.level = NSWindow.Level(rawValue: NSWindow.Level.modalPanel.rawValue + 1) + panel.minSize = NSSize(width: 520, height: 380) + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + } + + private func configureEditor() { + textView.delegate = self + textView.frame = NSRect(x: 0, y: 0, width: 720, height: 420) + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = true + textView.importsGraphics = false + textView.allowsUndo = true + textView.usesFindBar = true + textView.font = .systemFont(ofSize: 17) + textView.textColor = .labelColor + textView.backgroundColor = .textBackgroundColor + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.containerSize = NSSize(width: 0, + height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + if #available(macOS 15.0, *) { + textView.writingToolsBehavior = .complete + } + } + + private func buildInterface() { + let effect = NSVisualEffectView() + effect.material = .sheet + effect.blendingMode = .withinWindow + effect.state = .active + panel.contentView = effect + + let content = NSView() + content.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(content) + + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancel)) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + + saveButton.title = "Save" + saveButton.target = self + saveButton.action = #selector(save) + saveButton.bezelStyle = .rounded + saveButton.keyEquivalent = "\r" + saveButton.keyEquivalentModifierMask = [.command] + saveButton.bezelColor = .controlAccentColor + saveButton.toolTip = "Save (⌘↩)" + + let formatting = NSStackView(views: [ + toolbarTextButton("B", tooltip: "Bold", action: #selector(toggleBold), + font: .systemFont(ofSize: 17, weight: .bold)), + toolbarTextButton("I", tooltip: "Italic", action: #selector(toggleItalic), + font: NSFontManager.shared.convert( + .systemFont(ofSize: 17, weight: .semibold), + toHaveTrait: .italicFontMask + )), + toolbarTextButton("U", tooltip: "Underline", action: #selector(toggleUnderline), + underline: true), + toolbarTextButton("S", tooltip: "Strikethrough", action: #selector(toggleStrikethrough), + strikethrough: true) + ]) + formatting.orientation = .horizontal + formatting.spacing = 6 + + if writingToolsAvailable { + formatting.addArrangedSubview( + toolbarSymbolButton(symbol: "pencil.and.scribble", + tooltip: "Writing Tools", + action: #selector(showWritingTools))) + } + + let leadingSpacer = flexibleSpacer() + let trailingSpacer = flexibleSpacer() + let toolbar = NSStackView(views: [cancelButton, leadingSpacer, formatting, trailingSpacer, saveButton]) + toolbar.orientation = .horizontal + toolbar.alignment = .centerY + toolbar.spacing = 10 + + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .lineBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = true + scrollView.backgroundColor = .textBackgroundColor + scrollView.documentView = textView + scrollView.wantsLayer = true + scrollView.layer?.cornerRadius = 10 + + statsLabel.font = .systemFont(ofSize: 13, weight: .regular) + statsLabel.textColor = .secondaryLabelColor + statsLabel.lineBreakMode = .byTruncatingTail + + for view in [toolbar, scrollView, statsLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(view) + } + + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 16), + content.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -16), + content.topAnchor.constraint(equalTo: effect.topAnchor, constant: 14), + content.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -16), + + toolbar.leadingAnchor.constraint(equalTo: content.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: content.trailingAnchor), + toolbar.topAnchor.constraint(equalTo: content.topAnchor), + toolbar.heightAnchor.constraint(equalToConstant: 36), + + scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: toolbar.bottomAnchor, constant: 12), + scrollView.bottomAnchor.constraint(equalTo: statsLabel.topAnchor, constant: -10), + scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 260), + + statsLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 4), + statsLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -4), + statsLabel.bottomAnchor.constraint(equalTo: content.bottomAnchor), + statsLabel.heightAnchor.constraint(equalToConstant: 18) + ]) + + leadingSpacer.widthAnchor.constraint(equalTo: trailingSpacer.widthAnchor).isActive = true + } + + private func loadInitialContent() { + if item.type == .richText, + let data = item.rtfData, + let value = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil + ) { + textView.textStorage?.setAttributedString(value) + } else { + textView.string = item.text ?? "" + } + textView.setSelectedRange(NSRange(location: 0, length: 0)) + } + + private var writingToolsAvailable: Bool { + guard #available(macOS 15.2, *) else { return false } + return NSWritingToolsCoordinator.isWritingToolsAvailable + } + + private func toolbarTextButton(_ title: String, + tooltip: String, + action: Selector, + font: NSFont = .systemFont(ofSize: 17, weight: .semibold), + underline: Bool = false, + strikethrough: Bool = false) -> NSButton { + let button = configuredToolbarButton(tooltip: tooltip, action: action) + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.labelColor + ] + if underline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } + if strikethrough { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } + button.attributedTitle = NSAttributedString(string: title, attributes: attributes) + return button + } + + private func toolbarSymbolButton(symbol: String, + tooltip: String, + action: Selector) -> NSButton { + let button = configuredToolbarButton(tooltip: tooltip, action: action) + let configuration = NSImage.SymbolConfiguration(pointSize: 17, weight: .semibold) + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: tooltip)? + .withSymbolConfiguration(configuration) + button.image?.isTemplate = true + button.imagePosition = .imageOnly + return button + } + + private func configuredToolbarButton(tooltip: String, + action: Selector) -> NSButton { + let button = NSButton() + button.bezelStyle = .rounded + button.bezelColor = .controlBackgroundColor + button.contentTintColor = .labelColor + button.target = self + button.action = action + button.toolTip = tooltip + button.setAccessibilityLabel(tooltip) + button.widthAnchor.constraint(equalToConstant: 38).isActive = true + button.heightAnchor.constraint(equalToConstant: 32).isActive = true + return button + } + + private func flexibleSpacer() -> NSView { + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return spacer + } + + @objc private func cancel() { + finish(with: nil) + } + + @objc private func save() { + let text = textView.string + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let shouldSaveRichText = item.type == .richText || appliedRichFormatting + let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) + let richTextData = shouldSaveRichText ? textView.rtf(from: range) : nil + finish(with: .text(text, richTextData: richTextData)) + } + + @objc private func showWritingTools() { + guard #available(macOS 15.2, *), NSWritingToolsCoordinator.isWritingToolsAvailable else { return } + panel.makeFirstResponder(textView) + textView.showWritingTools(nil) + } + + @objc private func toggleBold() { + toggleFontTrait(.boldFontMask) + } + + @objc private func toggleItalic() { + toggleFontTrait(.italicFontMask) + } + + @objc private func toggleUnderline() { + toggleDecoration(.underlineStyle, enabledValue: NSUnderlineStyle.single.rawValue) + } + + @objc private func toggleStrikethrough() { + toggleDecoration(.strikethroughStyle, enabledValue: NSUnderlineStyle.single.rawValue) + } + + private func toggleFontTrait(_ trait: NSFontTraitMask) { + let range = textView.selectedRange() + let currentFont = font(at: range.location) + let isEnabled = NSFontManager.shared.traits(of: currentFont).contains(trait) + let transform: (NSFont) -> NSFont = { font in + isEnabled + ? NSFontManager.shared.convert(font, toNotHaveTrait: trait) + : NSFontManager.shared.convert(font, toHaveTrait: trait) + } + + applyAttribute(.font, range: range, transform: transform) + } + + private func toggleDecoration(_ key: NSAttributedString.Key, enabledValue: Int) { + let range = textView.selectedRange() + let current = decorationValue(for: key, at: range.location) + let target = current == 0 ? enabledValue : 0 + + if range.length == 0 { + var attributes = textView.typingAttributes + attributes[key] = target + textView.typingAttributes = attributes + } else { + textView.textStorage?.addAttribute(key, value: target, range: range) + } + appliedRichFormatting = true + panel.makeFirstResponder(textView) + } + + private func applyAttribute(_ key: NSAttributedString.Key, + range: NSRange, + transform: (NSFont) -> NSFont) { + if range.length == 0 { + var attributes = textView.typingAttributes + let font = (attributes[key] as? NSFont) ?? textView.font ?? .systemFont(ofSize: 17) + attributes[key] = transform(font) + textView.typingAttributes = attributes + } else if let storage = textView.textStorage { + storage.beginEditing() + storage.enumerateAttribute(key, in: range, options: []) { value, subrange, _ in + let font = (value as? NSFont) ?? self.textView.font ?? .systemFont(ofSize: 17) + storage.addAttribute(key, value: transform(font), range: subrange) + } + storage.endEditing() + } + appliedRichFormatting = true + panel.makeFirstResponder(textView) + } + + private func font(at location: Int) -> NSFont { + guard let storage = textView.textStorage, storage.length > 0 else { + return (textView.typingAttributes[.font] as? NSFont) ?? textView.font ?? .systemFont(ofSize: 17) + } + let safeLocation = min(max(location, 0), storage.length - 1) + return (storage.attribute(.font, at: safeLocation, effectiveRange: nil) as? NSFont) + ?? textView.font + ?? .systemFont(ofSize: 17) + } + + private func decorationValue(for key: NSAttributedString.Key, at location: Int) -> Int { + guard let storage = textView.textStorage, storage.length > 0 else { + return textView.typingAttributes[key] as? Int ?? 0 + } + let safeLocation = min(max(location, 0), storage.length - 1) + return storage.attribute(key, at: safeLocation, effectiveRange: nil) as? Int ?? 0 + } + + private func updateStats() { + let text = textView.string + let characters = text.count + let words = text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).count + let lines = text.isEmpty ? 0 : text.components(separatedBy: .newlines).count + let characterStat = "\(characters) \(countLabel(characters, singular: "character"))" + let wordStat = "\(words) \(countLabel(words, singular: "word"))" + let lineStat = "\(lines) \(countLabel(lines, singular: "line"))" + statsLabel.stringValue = [characterStat, wordStat, lineStat].joined(separator: " · ") + saveButton.isEnabled = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func countLabel(_ count: Int, singular: String) -> String { + count == 1 ? singular : "\(singular)s" + } + + private func finish(with value: ClipEditor.Edit?) { + result = value + panel.orderOut(nil) + NSApp.stopModal() + } +} + +@MainActor +private final class ColorEditorAccessoryView: NSStackView { + private let colorWell: NSColorWell + private let valueLabel: NSTextField + + init(color: NSColor) { + colorWell = NSColorWell() + valueLabel = NSTextField(labelWithString: color.hexString) + super.init(frame: NSRect(x: 0, y: 0, width: 260, height: 32)) + + orientation = .horizontal + alignment = .centerY + spacing = 10 + + let label = NSTextField(labelWithString: "Color:") + valueLabel.font = .monospacedSystemFont(ofSize: 12, weight: .medium) + valueLabel.textColor = .secondaryLabelColor + colorWell.color = color + colorWell.target = self + colorWell.action = #selector(colorDidChange) + colorWell.widthAnchor.constraint(equalToConstant: 42).isActive = true + + addArrangedSubview(label) + addArrangedSubview(colorWell) + addArrangedSubview(valueLabel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + var selectedHex: String { colorWell.color.hexString } + + @objc private func colorDidChange() { + valueLabel.stringValue = selectedHex + } +} diff --git a/Sources/Pesty/UI/ClipPreviewView.swift b/Sources/Pesty/UI/ClipPreviewView.swift new file mode 100644 index 0000000..901a4de --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewView.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct ClipPreviewView: View { + let item: ClipItem + + private var store: ClipboardStore { ClipboardStore.shared } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image(systemName: item.type.symbol) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(item.type.accent) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(item.displayTitle) + .font(.headline) + .lineLimit(2) + Text(item.type.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + ScrollView { + preview + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .padding(20) + .frame(minWidth: 400, minHeight: 260) + } + + @ViewBuilder + private var preview: some View { + switch item.type { + case .image: + if let image = store.loadImage(for: item) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + unavailable("The original image is no longer available.") + } + case .color: + VStack(alignment: .leading, spacing: 12) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(hex: item.colorHex ?? "#000000") ?? .black) + .frame(height: 180) + Text(item.colorHex ?? "Color") + .font(.system(.title3, design: .monospaced)) + .textSelection(.enabled) + } + case .file: + VStack(alignment: .leading, spacing: 10) { + ForEach(item.fileURLs, id: \.self) { value in + let url = URL(string: value) + HStack(alignment: .top, spacing: 9) { + Image(systemName: "doc") + .foregroundStyle(.secondary) + Text(url?.path ?? value) + .textSelection(.enabled) + } + } + } + case .text, .richText, .link: + if let text = item.text, !text.isEmpty { + Text(text) + .font(.system(size: 14)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + unavailable("This clip has no text to preview.") + } + } + } + + private func unavailable(_ message: String) -> some View { + ContentUnavailableView("Preview Unavailable", + systemImage: "eye.slash", + description: Text(message)) + } +}