Files
clipbored/sources/clipbored/views/ClipboardPanelViewModel.swift

412 lines
13 KiB
Swift
Raw Normal View History

2026-06-30 01:12:19 -07:00
import Foundation
import AppKit
final class ClipboardPanelViewModel {
private(set) var visibleItems: [ClipboardItem] = [] {
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
}
var searchText: String = "" {
didSet {
guard oldValue != searchText else { return }
selectedItemID = selectedItem?.id
recomputeVisibleItems()
}
}
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
selectedCollectionName = nil
settings.defaultSortMode = sortMode
recomputeVisibleItems()
onSortModeChanged?(sortMode)
}
}
private(set) var selectedCollectionName: String? {
didSet {
guard oldValue != selectedCollectionName else { return }
recomputeVisibleItems()
onCollectionsChanged?()
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
private(set) var statusMessage: String = "" {
didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } }
}
private var items: [ClipboardItem] = []
private let store: ClipboardStore
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let pasteService: PasteActionService
private var selectedItemID: UUID?
var targetApplicationProvider: () -> NSRunningApplication? = { nil }
var willPasteToTarget: () -> Void = {}
var onVisibleItemsChanged: (([ClipboardItem]) -> Void)?
var onSelectedIndexChanged: ((Int) -> Void)?
var onStatusMessageChanged: ((String) -> Void)?
var onSortModeChanged: ((ClipboardSortMode) -> Void)?
var onCollectionsChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) {
self.store = store
self.settings = settings
self.cacheService = cacheService
self.sortMode = settings.defaultSortMode
self.pasteService = PasteActionService(cacheService: cacheService)
store.observeItems { [weak self] list in
self?.notifyMain {
self?.items = list
self?.recomputeVisibleItems()
}
}
settings.observe { [weak self] change in
guard case .captureStatus = change else { return }
self?.notifyMain {
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
}
}
}
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
return visibleItems[selectedIndex]
}
var totalItemCount: Int {
items.count
}
var collectionNames: [String] {
let assignedNames = Set(
items.compactMap { item -> String? in
ClipboardCollectionDefaults.normalizedName(item.collectionName)
}
)
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) }
let customNames = assignedNames
.filter { !ClipboardCollectionDefaults.names.contains($0) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
return defaultNames + customNames
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode).count
}
func collectionCount(named name: String) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: name).count
}
var captureStatusMessage: String {
settings.captureStatusMessage
}
func thumbnail(for item: ClipboardItem) -> NSImage? {
cacheService.previewThumbnail(for: item)
}
func selectItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectedIndex = index
}
func selectFirstItem() {
guard !visibleItems.isEmpty else { return }
selectedItemID = nil
if selectedIndex == 0 {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = 0
}
}
func moveSelection(_ delta: Int) {
let count = visibleItems.count
guard count > 0 else { return }
let target = max(0, min(count - 1, selectedIndex + delta))
selectedIndex = target
}
func pasteSelected() {
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func copySelected() {
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
2026-06-30 01:24:01 -07:00
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index])
}
2026-06-30 01:12:19 -07:00
func openSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .url:
guard let url = URL(string: item.payload) else { return }
NSWorkspace.shared.open(url)
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
default:
break
}
}
func revealSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
default:
break
}
}
func deleteSelected() {
guard let item = selectedItem else { return }
store.remove(item.id)
let next = max(0, min(visibleItems.count - 2, selectedIndex))
selectedIndex = next
}
func togglePinSelected() {
guard let item = selectedItem else { return }
store.togglePin(item.id)
}
func assignSelected(to collectionName: String?) {
guard let item = selectedItem else { return }
selectedItemID = item.id
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
store.setCollection(item.id, name: normalizedName)
if let normalizedName {
statusMessage = "Added to \(normalizedName)"
} else {
statusMessage = "Removed from collection"
}
}
func selectCollection(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
selectedCollectionName = normalizedName
}
func clearSearch() {
searchText = ""
}
func recomputeVisibleItems() {
let previousSelection = selectedItemID
let query = searchText.clipboardTrimmed.lowercased()
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
selectedIndex = index
} else if selectedIndex >= visibleItems.count {
selectedIndex = max(0, visibleItems.count - 1)
} else if selectedIndex < 0 {
selectedIndex = 0
}
if visibleItems.isEmpty {
selectedIndex = 0
}
selectedItemID = selectedItem?.id
onCollectionsChanged?()
}
internal func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil
) -> [ClipboardItem] {
let tokens = searchTokens(from: query.lowercased())
let filtered = tokens.isEmpty
? items.enumerated().map { ($0.offset, $0.element) }
: items.enumerated().compactMap { index, item in
let text = searchableText(for: item)
return tokens.allSatisfy { text.contains($0) } ? (index, item) : nil
}
let collectionFiltered: [(Int, ClipboardItem)]
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
collectionFiltered = filtered.filter {
$0.1.collectionName?.caseInsensitiveCompare(collectionName) == .orderedSame
}
} else {
collectionFiltered = filtered
}
func fallback(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
return lhs.0 < rhs.0
}
func sortByUsage(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
if lhs.1.lastUsedAt == rhs.1.lastUsedAt {
return fallback(lhs, rhs)
}
return lhs.1.lastUsedAt > rhs.1.lastUsedAt
}
if ClipboardCollectionDefaults.normalizedName(collectionName) != nil {
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
}
switch sortMode {
case .mostRecent:
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
case .mostUsed:
return collectionFiltered
.sorted {
if $0.1.useCount == $1.1.useCount { return sortByUsage($0, $1) }
return $0.1.useCount > $1.1.useCount
}
.map(\.1)
case .images:
return collectionFiltered
.filter { $0.1.kind == .image }
.sorted(by: sortByUsage)
.map(\.1)
case .links:
return collectionFiltered
.filter { $0.1.kind == .url }
.sorted(by: sortByUsage)
.map(\.1)
case .text:
return collectionFiltered
.filter { $0.1.kind == .text || $0.1.kind == .richText }
.sorted(by: sortByUsage)
.map(\.1)
case .files:
return collectionFiltered
.filter { $0.1.kind == .file || $0.1.kind == .pdf }
.sorted(by: sortByUsage)
.map(\.1)
case .audio:
return collectionFiltered
.filter { $0.1.kind == .audio }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt {
return fallback($0, $1)
}
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
@unknown default:
return collectionFiltered
.sorted(by: { lhs, rhs in
return fallback(lhs, rhs)
})
.map(\.1)
}
}
private func searchableText(for item: ClipboardItem) -> String {
var base = item.searchableText
if settings.includeImageTextInSearch, let ocrText = item.ocrText {
base += " \(ocrText.lowercased())"
}
return base
}
private func searchTokens(from query: String) -> [String] {
query
.split { character in
character.isWhitespace || character.isPunctuation
}
.map(String.init)
}
private func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
}