From c9c792d91a30a3006f0e8f0b76d7392dd53d6e8b Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:31:52 -0700 Subject: [PATCH 1/6] Add clip context menu actions --- Sources/Pesty/AppController.swift | 78 ++++++++++++++++++-- Sources/Pesty/Models/ClipItem.swift | 18 +++++ Sources/Pesty/Monitor/PasteService.swift | 16 ++++- Sources/Pesty/UI/ClipCardView.swift | 88 ++++++++++++++++++----- Sources/Pesty/UI/ClipPreviewView.swift | 90 ++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 Sources/Pesty/UI/ClipPreviewView.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..d09d005 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -12,6 +12,7 @@ final class AppController: NSObject, NSApplicationDelegate { private var barController: BarWindowController? private var statusItem: NSStatusItem? private var settingsWindow: NSWindow? + private var previewWindow: NSWindow? private var keyMonitor: Any? private(set) var previousApp: NSRunningApplication? @@ -131,7 +132,7 @@ 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 } store.searchText = "" @@ -152,13 +153,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? { + [previousApp, lastActiveApp, 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) { @@ -167,6 +185,56 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func showPreview(for item: ClipItem) { + let host = NSHostingController(rootView: ClipPreviewView(item: item)) + let title = "Preview — \(item.displayTitle)" + + 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 { diff --git a/Sources/Pesty/Models/ClipItem.swift b/Sources/Pesty/Models/ClipItem.swift index b3689df..7111ca2 100644 --- a/Sources/Pesty/Models/ClipItem.swift +++ b/Sources/Pesty/Models/ClipItem.swift @@ -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 { diff --git a/Sources/Pesty/Monitor/PasteService.swift b/Sources/Pesty/Monitor/PasteService.swift index 72676fb..b67bc0c 100644 --- a/Sources/Pesty/Monitor/PasteService.swift +++ b/Sources/Pesty/Monitor/PasteService.swift @@ -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 @@ -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() } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..9b165bc 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -173,29 +173,83 @@ 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") + } + .keyboardShortcut(.return, modifiers: []) + + Button { AppController.shared.pasteItem(item, asPlainText: true) } label: { + Label("Paste as Plain Text", systemImage: "text.alignleft") + } + .keyboardShortcut(.return, modifiers: .shift) + .disabled(item.plainText == nil) + + Button { AppController.shared.copyItem(item) } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .keyboardShortcut("c", modifiers: .command) + + Divider() + + Button { renameItem() } label: { + Label("Rename…", systemImage: "pencil") + } + .keyboardShortcut("r", modifiers: .command) + + Button(role: .destructive) { store.delete(item) } label: { + Label("Delete", systemImage: "trash") + } + .keyboardShortcut(.delete, modifiers: []) + Divider() - if !store.pinboards.isEmpty { - Menu("Save to Pinboard") { + + 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(systemName: "circle.fill") + .foregroundStyle(b.color) + } + } } } - } - 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) - } - } - Button("Edit Title…") { - if let t = TextPrompt.run(title: "Edit Title", message: "Card title", - defaultValue: item.customTitle ?? "") { - store.setTitle(t, for: item) + Divider() + Button { pinToNewBoard() } label: { + Label("Create Pinboard…", systemImage: "plus") } + } label: { + Label("Pin", systemImage: "pin") } + Divider() - Button("Delete", role: .destructive) { store.delete(item) } + + Button { AppController.shared.showPreview(for: item) } label: { + Label("Preview", systemImage: "eye") + } + .keyboardShortcut(.space, modifiers: []) + + Button { AppController.shared.showSharePicker(for: item) } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + } + + private func renameItem() { + if let title = TextPrompt.run(title: "Rename", message: "Enter a name", + defaultValue: item.displayTitle) { + 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) + } } } diff --git a/Sources/Pesty/UI/ClipPreviewView.swift b/Sources/Pesty/UI/ClipPreviewView.swift new file mode 100644 index 0000000..dbfeb79 --- /dev/null +++ b/Sources/Pesty/UI/ClipPreviewView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// A read-only preview for a clip. It deliberately keeps editing out of this +/// surface: Rename remains a separate, explicit contextual action. +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)) + } +} From ad4c250751b33a6538b5f7a4caa18ee86313ef5f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:37:48 -0700 Subject: [PATCH 2/6] Add editable clips and Writing Tools --- Sources/Pesty/AppController.swift | 25 ++++ Sources/Pesty/Store/ClipboardStore.swift | 81 ++++++++++++ Sources/Pesty/UI/ClipCardView.swift | 19 +++ Sources/Pesty/UI/ClipEditor.swift | 154 +++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 Sources/Pesty/UI/ClipEditor.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index d09d005..1ccbfc2 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -13,6 +13,7 @@ final class AppController: NSObject, NSApplicationDelegate { 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? @@ -185,9 +186,33 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + 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 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 diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..c370bfb 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -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) { diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 9b165bc..8a33f38 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -191,6 +192,18 @@ struct ClipCardView: View { Divider() + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") + } + .keyboardShortcut("e", modifiers: .command) + + if writingToolsAvailable { + Button { AppController.shared.editItem(item, launchWritingTools: true) } label: { + Label("Writing Tools", systemImage: "pencil.and.scribble") + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + } + Button { renameItem() } label: { Label("Rename…", systemImage: "pencil") } @@ -252,4 +265,10 @@ struct ClipCardView: View { store.saveToPinboard(item, boardID: board.id) } } + + 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 + } } diff --git a/Sources/Pesty/UI/ClipEditor.swift b/Sources/Pesty/UI/ClipEditor.swift new file mode 100644 index 0000000..ac67486 --- /dev/null +++ b/Sources/Pesty/UI/ClipEditor.swift @@ -0,0 +1,154 @@ +import AppKit + +/// Native modal editing for a clip's payload, separate from the card title. +/// Persistence and pasteboard updates stay in AppController and ClipboardStore. +@MainActor +enum ClipEditor { + enum Edit { + case text(String, richTextData: Data?) + case color(String) + } + + static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? { + switch item.type { + case .text, .richText, .link: + return editText(item, launchWritingTools: launchWritingTools) + case .color: + return editColor(item) + case .image, .file: + showUnsupportedEditor(for: item) + return nil + } + } + + private static func editText(_ item: ClipItem, launchWritingTools: Bool) -> Edit? { + let isRichText = item.type == .richText + let alert = NSAlert() + alert.messageText = "Edit \(item.type.label)" + alert.informativeText = isRichText + ? "Edit this saved clip. Its rich-text formatting is preserved when possible." + : "Edit this saved clip's contents." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + textView.isRichText = isRichText + textView.importsGraphics = false + textView.allowsUndo = true + textView.isEditable = true + textView.isSelectable = true + textView.font = .systemFont(ofSize: 13) + textView.textContainer?.containerSize = NSSize(width: 430, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + if #available(macOS 15.0, *) { + textView.writingToolsBehavior = .complete + } + + if isRichText, + 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 ?? "" + } + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.documentView = textView + alert.accessoryView = scrollView + alert.window.initialFirstResponder = textView + + if launchWritingTools { + DispatchQueue.main.async { + guard #available(macOS 15.2, *), + NSWritingToolsCoordinator.isWritingToolsAvailable else { return } + alert.window.makeFirstResponder(textView) + textView.showWritingTools(nil) + } + } + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let text = textView.string + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + showEmptyTextWarning() + return nil + } + + let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) + let richTextData = isRichText ? textView.rtf(from: range) : nil + return .text(text, richTextData: richTextData) + } + + private static func editColor(_ item: ClipItem) -> Edit? { + let alert = NSAlert() + alert.messageText = "Edit Color" + alert.informativeText = "Choose the color stored in this saved 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() + } + + private static func showEmptyTextWarning() { + let alert = NSAlert() + alert.messageText = "Clip content can't be empty" + alert.informativeText = "Enter some text before saving this clip." + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +@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 + } +} From 48c8b5bea5089962984090d308fa4bc5cc4b6fb9 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:38:35 -0700 Subject: [PATCH 3/6] Use a distinct Rename menu icon --- Sources/Pesty/UI/ClipCardView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 8a33f38..b231928 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -205,7 +205,7 @@ struct ClipCardView: View { } Button { renameItem() } label: { - Label("Rename…", systemImage: "pencil") + Label("Rename…", systemImage: "pencil.line") } .keyboardShortcut("r", modifiers: .command) From 3020ca2dc2e69dcd4e559b3911b094c313dcb010 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:44:49 -0700 Subject: [PATCH 4/6] Keep native editing keys inside the editor --- Sources/Pesty/AppController.swift | 11 ++++++++++- Sources/Pesty/UI/ClipCardView.swift | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 1ccbfc2..c350b09 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -188,7 +188,16 @@ final class AppController: NSObject, NSApplicationDelegate { func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { suppressAutoHide = true - defer { suppressAutoHide = false } + // 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 } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index b231928..37da27a 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -226,8 +226,8 @@ struct ClipCardView: View { Label { Text(b.name) } icon: { - Image(systemName: "circle.fill") - .foregroundStyle(b.color) + Image(nsImage: Self.pinboardMenuIcon(color: NSColor(b.color))) + .renderingMode(.original) } } } @@ -271,4 +271,18 @@ struct ClipCardView: View { guard #available(macOS 15.2, *) else { return false } return NSWritingToolsCoordinator.isWritingToolsAvailable } + + /// SwiftUI's symbol tint is converted to a template image when rendered in + /// an NSMenu. Draw the pinboard color into a non-template image instead so + /// the native submenu keeps the colored dots shown throughout Pesty. + 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 + } } From 643e0a0f9b733913bcba7c4610160be5b2872b5f Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 24 Jul 2026 14:45:21 -0700 Subject: [PATCH 5/6] Keep context-menu commands scoped to their menu --- Sources/Pesty/AppController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index c350b09..e5c1929 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -135,6 +135,7 @@ final class AppController: NSObject, NSApplicationDelegate { let front = NSWorkspace.shared.frontmostApplication if let front, !isPesty(front) { previousApp = front + lastActiveApp = front } store.searchText = "" store.source = .history @@ -161,7 +162,7 @@ final class AppController: NSObject, NSApplicationDelegate { /// `previousApp` is captured before the panel activates, while /// `lastActiveApp` covers menu-bar and reopen paths where it is unavailable. private var pasteTarget: NSRunningApplication? { - [previousApp, lastActiveApp, NSWorkspace.shared.frontmostApplication] + [lastActiveApp, previousApp, NSWorkspace.shared.frontmostApplication] .compactMap { $0 } .first { !$0.isTerminated && !isPesty($0) } } @@ -300,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) From 5055c2abc099b1c67c0f452c57e4f2fe6d54b79b Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Fri, 31 Jul 2026 17:38:09 -0700 Subject: [PATCH 6/6] Polish the clip editor --- Sources/Pesty/UI/ClipEditor.swift | 470 ++++++++++++++++++++++++++---- 1 file changed, 409 insertions(+), 61 deletions(-) diff --git a/Sources/Pesty/UI/ClipEditor.swift b/Sources/Pesty/UI/ClipEditor.swift index ac67486..4c88329 100644 --- a/Sources/Pesty/UI/ClipEditor.swift +++ b/Sources/Pesty/UI/ClipEditor.swift @@ -1,7 +1,8 @@ import AppKit -/// Native modal editing for a clip's payload, separate from the card title. -/// Persistence and pasteboard updates stay in AppController and ClipboardStore. +/// Edits a clip's payload without conflating it with the optional card title. +/// Text editing is intentionally a dedicated surface, rather than an alert, +/// so long clipboard entries remain comfortable to read and format. @MainActor enum ClipEditor { enum Edit { @@ -10,9 +11,11 @@ enum ClipEditor { } static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? { + NSApp.activate(ignoringOtherApps: true) switch item.type { case .text, .richText, .link: - return editText(item, launchWritingTools: launchWritingTools) + return TextClipEditorController(item: item, + launchWritingTools: launchWritingTools).run() case .color: return editColor(item) case .image, .file: @@ -21,30 +24,244 @@ enum ClipEditor { } } - private static func editText(_ item: ClipItem, launchWritingTools: Bool) -> Edit? { - let isRichText = item.type == .richText + private static func editColor(_ item: ClipItem) -> Edit? { let alert = NSAlert() - alert.messageText = "Edit \(item.type.label)" - alert.informativeText = isRichText - ? "Edit this saved clip. Its rich-text formatting is preserved when possible." - : "Edit this saved clip's contents." + alert.messageText = "Edit Color" + alert.informativeText = "Choose the color stored in this saved clip." alert.addButton(withTitle: "Save") alert.addButton(withTitle: "Cancel") - let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) - textView.isRichText = isRichText - textView.importsGraphics = false - textView.allowsUndo = true + 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 { + // Keep the modal editor alive through the next run-loop turn so + // Writing Tools is requested only after the text view is key. + 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 + // The Paste Bar deliberately sits at the modal-panel level so it can + // stay visible without activating Pesty. The editor must clear that + // surface while it owns focus. + 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.font = .systemFont(ofSize: 13) - textView.textContainer?.containerSize = NSSize(width: 430, height: CGFloat.greatestFiniteMagnitude) + 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() + // A sheet material keeps the toolbar legible regardless of what is + // behind Pesty. The HUD/behind-window combination made the controls + // inherit a low-contrast blue treatment. + 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.bezelColor = .controlAccentColor + saveButton.contentTintColor = .white + saveButton.attributedTitle = NSAttributedString( + string: "Save", + attributes: [ + .font: NSFont.systemFont(ofSize: 16, weight: .semibold), + .foregroundColor: NSColor.white + ] + ) + + 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 toolbar = NSStackView() + toolbar.orientation = .horizontal + toolbar.alignment = .centerY + toolbar.spacing = 10 + let leadingSpacer = flexibleSpacer() + let trailingSpacer = flexibleSpacer() + toolbar.addArrangedSubview(cancelButton) + toolbar.addArrangedSubview(leadingSpacer) + toolbar.addArrangedSubview(formatting) + toolbar.addArrangedSubview(trailingSpacer) + toolbar.addArrangedSubview(saveButton) + + 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), - if isRichText, + 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, @@ -55,65 +272,196 @@ enum ClipEditor { } else { textView.string = item.text ?? "" } + textView.setSelectedRange(NSRange(location: 0, length: 0)) + } - let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) - scrollView.borderType = .bezelBorder - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.documentView = textView - alert.accessoryView = scrollView - alert.window.initialFirstResponder = textView + private var writingToolsAvailable: Bool { + guard #available(macOS 15.2, *) else { return false } + return NSWritingToolsCoordinator.isWritingToolsAvailable + } - if launchWritingTools { - DispatchQueue.main.async { - guard #available(macOS 15.2, *), - NSWritingToolsCoordinator.isWritingToolsAvailable else { return } - alert.window.makeFirstResponder(textView) - textView.showWritingTools(nil) - } - } + 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 + } - guard alert.runModal() == .alertFirstButtonReturn else { return nil } + 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 { - showEmptyTextWarning() - return nil - } + 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 = isRichText ? textView.rtf(from: range) : nil - return .text(text, richTextData: richTextData) + let richTextData = shouldSaveRichText ? textView.rtf(from: range) : nil + finish(with: .text(text, richTextData: richTextData)) } - private static func editColor(_ item: ClipItem) -> Edit? { - let alert = NSAlert() - alert.messageText = "Edit Color" - alert.informativeText = "Choose the color stored in this saved clip." - alert.addButton(withTitle: "Save") - alert.addButton(withTitle: "Cancel") + @objc private func showWritingTools() { + guard #available(macOS 15.2, *), NSWritingToolsCoordinator.isWritingToolsAvailable else { return } + panel.makeFirstResponder(textView) + textView.showWritingTools(nil) + } - let color = item.colorHex.flatMap(NSColor.init(hex:)) ?? .black - let accessory = ColorEditorAccessoryView(color: color) - alert.accessoryView = accessory + @objc private func toggleBold() { + toggleFontTrait(.boldFontMask) + } - guard alert.runModal() == .alertFirstButtonReturn else { return nil } - return .color(accessory.selectedHex) + @objc private func toggleItalic() { + toggleFontTrait(.italicFontMask) } - 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() + @objc private func toggleUnderline() { + toggleDecoration(.underlineStyle, enabledValue: NSUnderlineStyle.single.rawValue) } - private static func showEmptyTextWarning() { - let alert = NSAlert() - alert.messageText = "Clip content can't be empty" - alert.informativeText = "Enter some text before saving this clip." - alert.addButton(withTitle: "OK") - alert.runModal() + @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: " · ") + let canSave = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + saveButton.isEnabled = canSave + saveButton.attributedTitle = NSAttributedString( + string: "Save", + attributes: [ + .font: NSFont.systemFont(ofSize: 16, weight: .semibold), + .foregroundColor: canSave ? NSColor.white : NSColor.disabledControlTextColor + ] + ) + } + + 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() } }