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)) + } +}