From faa2e9389beac9fdd4e23a907f9bd7dedda800df Mon Sep 17 00:00:00 2001 From: pszsh Date: Sun, 15 Mar 2026 10:49:40 -0700 Subject: [PATCH] Added automatic switching, user configurable settings and lots of performance improvements, ie, caching, thumbnailing, sliding history buffer. Bugfixes too. --- bridge/shelf_core.h | 1 + build.sh | 2 +- core/src/clip.rs | 2 + core/src/lib.rs | 6 + core/src/store.rs | 8 +- debug.sh | 2 +- install.sh | 2 +- src/AppDelegate.swift | 21 ++ src/ClipItem.swift | 47 +++-- src/ClipStore.swift | 58 +++++- src/PasteboardMonitor.swift | 54 +++++ src/Settings.swift | 120 +++++++++++ src/ShelfView.swift | 396 ++++++++++++++++++++++++++++-------- 13 files changed, 604 insertions(+), 115 deletions(-) create mode 100644 src/Settings.swift diff --git a/bridge/shelf_core.h b/bridge/shelf_core.h index d162fcf..1135682 100644 --- a/bridge/shelf_core.h +++ b/bridge/shelf_core.h @@ -17,6 +17,7 @@ typedef struct { bool is_pinned; int32_t displaced_prev; int32_t displaced_next; + char *source_path; } ShelfClip; typedef struct { diff --git a/build.sh b/build.sh index 8e9ec9a..8bed7de 100755 --- a/build.sh +++ b/build.sh @@ -35,6 +35,6 @@ swiftc \ cp resources/Info.plist "$CONTENTS/" -codesign --force --sign - "$APP_BUNDLE" +codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE" echo "Built: $APP_BUNDLE" diff --git a/core/src/clip.rs b/core/src/clip.rs index b1f1a26..c79fe28 100644 --- a/core/src/clip.rs +++ b/core/src/clip.rs @@ -11,6 +11,7 @@ pub struct Clip { pub is_pinned: bool, pub displaced_prev: Option, pub displaced_next: Option, + pub source_path: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -60,6 +61,7 @@ impl Clip { is_pinned: row.get::<_, i32>(6)? != 0, displaced_prev: row.get(7)?, displaced_next: row.get(8)?, + source_path: row.get(9)?, }) } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 2fca765..440d394 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -20,6 +20,7 @@ pub struct ShelfClip { pub is_pinned: bool, pub displaced_prev: i32, pub displaced_next: i32, + pub source_path: *mut c_char, } #[repr(C)] @@ -78,6 +79,7 @@ pub extern "C" fn shelf_store_get_all(store: *mut Store) -> ShelfClipList { is_pinned: c.is_pinned, displaced_prev: c.displaced_prev.map(|v| v as i32).unwrap_or(-1), displaced_next: c.displaced_next.map(|v| v as i32).unwrap_or(-1), + source_path: opt_to_c(&c.source_path), }) .collect(); @@ -108,6 +110,9 @@ pub extern "C" fn shelf_clip_list_free(list: ShelfClipList) { if !clip.source_app.is_null() { drop(CString::from_raw(clip.source_app)); } + if !clip.source_path.is_null() { + drop(CString::from_raw(clip.source_path)); + } } } } @@ -132,6 +137,7 @@ pub extern "C" fn shelf_store_add( is_pinned: ffi.is_pinned, displaced_prev: if ffi.displaced_prev >= 0 { Some(ffi.displaced_prev as i64) } else { None }, displaced_next: if ffi.displaced_next >= 0 { Some(ffi.displaced_next as i64) } else { None }, + source_path: unsafe { from_c(ffi.source_path) }, }; let img = if !image_data.is_null() && image_len > 0 { diff --git a/core/src/store.rs b/core/src/store.rs index 5899b08..8081bd8 100644 --- a/core/src/store.rs +++ b/core/src/store.rs @@ -35,6 +35,7 @@ impl Store { conn.execute("ALTER TABLE clips ADD COLUMN displaced_prev INTEGER", []).ok(); conn.execute("ALTER TABLE clips ADD COLUMN displaced_next INTEGER", []).ok(); + conn.execute("ALTER TABLE clips ADD COLUMN source_path TEXT", []).ok(); Store { conn, @@ -48,7 +49,7 @@ impl Store { .conn .prepare( "SELECT id, timestamp, content_type, text_content, image_path, source_app, is_pinned, - displaced_prev, displaced_next + displaced_prev, displaced_next, source_path FROM clips ORDER BY is_pinned DESC, timestamp DESC", ) .unwrap(); @@ -103,8 +104,8 @@ impl Store { self.conn .execute( "INSERT OR REPLACE INTO clips - (id, timestamp, content_type, text_content, image_path, source_app, is_pinned) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + (id, timestamp, content_type, text_content, image_path, source_app, is_pinned, source_path) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ clip.id, clip.timestamp, @@ -113,6 +114,7 @@ impl Store { image_path, clip.source_app, clip.is_pinned as i32, + clip.source_path, ], ) .ok(); diff --git a/debug.sh b/debug.sh index 871c3e7..18b3314 100755 --- a/debug.sh +++ b/debug.sh @@ -38,7 +38,7 @@ swiftc \ cp resources/Info.plist "$CONTENTS/" -codesign --force --sign - "$APP_BUNDLE" +codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE" echo "Built: $APP_BUNDLE" open "$APP_BUNDLE" diff --git a/install.sh b/install.sh index 69d65ac..e7063a8 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ APP_NAME="Shelf" bash build.sh -killall "$APP_NAME" 2>/dev/null || true +killall "$APP_NAME" 2>/dev/null && sleep 1 || true rm -rf "/Applications/$APP_NAME.app" cp -R "build/$APP_NAME.app" "/Applications/$APP_NAME.app" open "/Applications/$APP_NAME.app" diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index cd76ac0..842e260 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -11,6 +11,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var clickMonitor: Any? private var hotkeyRef: EventHotKeyRef? private var lastHideTime: Date? + private var settingsWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { store = ClipStore() @@ -48,6 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let menu = NSMenu() menu.addItem(NSMenuItem(title: "Show Shelf", action: #selector(togglePanel), keyEquivalent: "")) menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",")) menu.addItem(NSMenuItem(title: "Clear All", action: #selector(clearAll), keyEquivalent: "")) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Quit Shelf", action: #selector(quit), keyEquivalent: "q")) @@ -100,6 +102,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc private func openSettings() { + if let w = settingsWindow, w.isVisible { + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, defer: false + ) + window.title = "Shelf Settings" + window.contentView = NSHostingView(rootView: SettingsView()) + window.center() + window.makeKeyAndOrderFront(nil) + settingsWindow = window + NSApp.activate(ignoringOtherApps: true) + } + @objc private func clearAll() { store.clearAll() } diff --git a/src/ClipItem.swift b/src/ClipItem.swift index 4c3f7d9..fb4a3c7 100644 --- a/src/ClipItem.swift +++ b/src/ClipItem.swift @@ -22,6 +22,7 @@ struct ClipItem: Identifiable { var rawImageData: Data? var displacedPrev: Int? var displacedNext: Int? + var sourceFilePath: String? var preview: String { switch contentType { @@ -34,14 +35,6 @@ struct ClipItem: Identifiable { } } - var relativeTime: String { - let interval = Date().timeIntervalSince(timestamp) - if interval < 60 { return "now" } - if interval < 3600 { return "\(Int(interval / 60))m" } - if interval < 86400 { return "\(Int(interval / 3600))h" } - return "\(Int(interval / 86400))d" - } - var sourceAppName: String? { guard let bundleID = sourceApp, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { @@ -59,13 +52,41 @@ struct ClipItem: Identifiable { } } + private static let thumbCache = NSCache() + func loadImage() -> NSImage? { - guard let path = imagePath else { - if let data = rawImageData { - return NSImage(data: data) - } + let key = id.uuidString as NSString + if let cached = Self.thumbCache.object(forKey: key) { + return cached + } + + let source: NSImage? + if let path = imagePath { + source = NSImage(contentsOfFile: path) + } else if let data = rawImageData { + source = NSImage(data: data) + } else { return nil } - return NSImage(contentsOfFile: path) + + guard let img = source else { return nil } + + let maxDim: CGFloat = 400 + let w = img.size.width, h = img.size.height + if w > maxDim || h > maxDim { + let scale = min(maxDim / w, maxDim / h) + let newSize = NSSize(width: w * scale, height: h * scale) + let thumb = NSImage(size: newSize) + thumb.lockFocus() + img.draw(in: NSRect(origin: .zero, size: newSize), + from: NSRect(origin: .zero, size: img.size), + operation: .copy, fraction: 1.0) + thumb.unlockFocus() + Self.thumbCache.setObject(thumb, forKey: key) + return thumb + } + + Self.thumbCache.setObject(img, forKey: key) + return img } } diff --git a/src/ClipStore.swift b/src/ClipStore.swift index 15d6a30..dcb00bc 100644 --- a/src/ClipStore.swift +++ b/src/ClipStore.swift @@ -37,7 +37,8 @@ class ClipStore: ObservableObject { sourceApp: c.source_app != nil ? String(cString: c.source_app) : nil, isPinned: c.is_pinned, displacedPrev: c.displaced_prev >= 0 ? Int(c.displaced_prev) : nil, - displacedNext: c.displaced_next >= 0 ? Int(c.displaced_next) : nil + displacedNext: c.displaced_next >= 0 ? Int(c.displaced_next) : nil, + sourceFilePath: c.source_path != nil ? String(cString: c.source_path) : nil )) } items = loaded @@ -47,10 +48,12 @@ class ClipStore: ObservableObject { let idStr = strdup(item.id.uuidString) let textStr = item.textContent.flatMap { strdup($0) } let appStr = item.sourceApp.flatMap { strdup($0) } + let srcStr = item.sourceFilePath.flatMap { strdup($0) } defer { free(idStr) free(textStr) free(appStr) + free(srcStr) } var clip = ShelfClip( @@ -62,7 +65,8 @@ class ClipStore: ObservableObject { source_app: appStr, is_pinned: item.isPinned, displaced_prev: -1, - displaced_next: -1 + displaced_next: -1, + source_path: srcStr ) if let data = item.rawImageData { @@ -95,21 +99,53 @@ class ClipStore: ObservableObject { let pb = NSPasteboard.general pb.clearContents() - switch item.contentType { - case .text: - pb.setString(item.textContent ?? "", forType: .string) - case .url: - pb.setString(item.textContent ?? "", forType: .string) - pb.setString(item.textContent ?? "", forType: .URL) - case .image: - if let image = item.loadImage() { - pb.writeObjects([image]) + if let path = item.sourceFilePath, + FileManager.default.fileExists(atPath: path) { + pb.writeObjects([NSURL(fileURLWithPath: path)]) + } else { + switch item.contentType { + case .text: + pb.setString(item.textContent ?? "", forType: .string) + case .url: + pb.setString(item.textContent ?? "", forType: .string) + pb.setString(item.textContent ?? "", forType: .URL) + case .image: + if let image = item.loadImage() { + pb.writeObjects([image]) + } } } NotificationCenter.default.post(name: .dismissShelfPanel, object: nil) } + func copyTextToClipboard(_ item: ClipItem) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(item.textContent ?? "", forType: .string) + NotificationCenter.default.post(name: .dismissShelfPanel, object: nil) + } + + func copyPathToClipboard(_ item: ClipItem) { + guard let path = item.sourceFilePath else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(path, forType: .string) + NotificationCenter.default.post(name: .dismissShelfPanel, object: nil) + } + + func editItem(_ item: ClipItem) { + if let path = item.sourceFilePath, + FileManager.default.fileExists(atPath: path) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } else if let text = item.textContent { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("shelf-\(item.id.uuidString.prefix(8)).txt") + try? text.write(to: url, atomically: true, encoding: .utf8) + NSWorkspace.shared.open(url) + } + } + func clearAll() { shelf_store_clear_all(storePtr) items.removeAll() diff --git a/src/PasteboardMonitor.swift b/src/PasteboardMonitor.swift index 205366b..32b2fe4 100644 --- a/src/PasteboardMonitor.swift +++ b/src/PasteboardMonitor.swift @@ -42,6 +42,60 @@ class PasteboardMonitor { let sourceApp = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], + let fileURL = urls.first { + let ext = fileURL.pathExtension.lowercased() + let textExts: Set = [ + "txt", "md", "markdown", "rtf", "csv", "json", "xml", "yaml", "yml", + "toml", "ini", "cfg", "conf", "log", "sh", "zsh", "bash", "fish", + "py", "rb", "js", "ts", "go", "rs", "c", "h", "cpp", "hpp", "java", + "swift", "kt", "html", "css", "scss", "less", "sql", "lua", "pl", + "r", "m", "mm", "zig", "nim", "ex", "exs", "erl", "hs", "ml", + "tex", "sty", "cls", "bib", "org", "rst", "adoc", "env", "gitignore", + "dockerfile", "makefile" + ] + let imageExts: Set = [ + "png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif", "ico", "svg" + ] + + let filePath = fileURL.path + + if textExts.contains(ext) || ext.isEmpty { + if let content = try? String(contentsOf: fileURL, encoding: .utf8), + content.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 { + let item = ClipItem( + id: UUID(), timestamp: now, contentType: .text, + textContent: content, imagePath: nil, + sourceApp: sourceApp, isPinned: false, + sourceFilePath: filePath + ) + onNewClip?(item) + return + } + } else if imageExts.contains(ext) { + if let data = try? Data(contentsOf: fileURL) { + let item = ClipItem( + id: UUID(), timestamp: now, contentType: .image, + textContent: nil, imagePath: nil, + sourceApp: sourceApp, isPinned: false, + rawImageData: data, + sourceFilePath: filePath + ) + onNewClip?(item) + return + } + } else { + let item = ClipItem( + id: UUID(), timestamp: now, contentType: .text, + textContent: filePath, imagePath: nil, + sourceApp: sourceApp, isPinned: false, + sourceFilePath: filePath + ) + onNewClip?(item) + return + } + } + if let imageData = pb.data(forType: .tiff) ?? pb.data(forType: .png) { let item = ClipItem( id: UUID(), timestamp: now, contentType: .image, diff --git a/src/Settings.swift b/src/Settings.swift new file mode 100644 index 0000000..521e19f --- /dev/null +++ b/src/Settings.swift @@ -0,0 +1,120 @@ +import SwiftUI + +enum DateDisplayMode: Int, CaseIterable { + case never = 0 + case fromYesterday = 1 + case afterHours = 2 + case always = 3 + + var label: String { + switch self { + case .never: return "Never" + case .fromYesterday: return "Yesterday onward" + case .afterHours: return "After threshold" + case .always: return "Always" + } + } +} + +class ShelfSettings: ObservableObject { + static let shared = ShelfSettings() + + @Published var showLineCount: Bool { + didSet { UserDefaults.standard.set(showLineCount, forKey: "showLineCount") } + } + @Published var showCharCount: Bool { + didSet { UserDefaults.standard.set(showCharCount, forKey: "showCharCount") } + } + @Published var dateDisplayMode: DateDisplayMode { + didSet { UserDefaults.standard.set(dateDisplayMode.rawValue, forKey: "dateDisplayMode") } + } + @Published var dateAfterHours: Int { + didSet { UserDefaults.standard.set(dateAfterHours, forKey: "dateAfterHours") } + } + + private init() { + let d = UserDefaults.standard + self.showLineCount = d.object(forKey: "showLineCount") as? Bool ?? true + self.showCharCount = d.object(forKey: "showCharCount") as? Bool ?? true + let raw = d.object(forKey: "dateDisplayMode") as? Int ?? DateDisplayMode.fromYesterday.rawValue + self.dateDisplayMode = DateDisplayMode(rawValue: raw) ?? .fromYesterday + self.dateAfterHours = d.object(forKey: "dateAfterHours") as? Int ?? 23 + } + + private static let clockFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mma" + f.amSymbol = "a" + f.pmSymbol = "p" + return f + }() + + private static let dateFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "M/d" + return f + }() + + func formatTimestamp(_ date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + var result: String + if interval < 60 { result = "now" } + else if interval < 3600 { result = "\(Int(interval / 60))m" } + else if interval < 86400 { result = "\(Int(interval / 3600))h" } + else { result = "\(Int(interval / 86400))d" } + + if interval >= 7200 { + result += " " + Self.clockFmt.string(from: date) + } + + let showDate: Bool + switch dateDisplayMode { + case .never: + showDate = false + case .fromYesterday: + showDate = !Calendar.current.isDateInToday(date) + case .afterHours: + showDate = interval >= Double(dateAfterHours) * 3600 + case .always: + showDate = true + } + + if showDate { + result += " " + Self.dateFmt.string(from: date) + } + + return result + } +} + +// MARK: - Settings View + +struct SettingsView: View { + @ObservedObject var settings = ShelfSettings.shared + + var body: some View { + Form { + Section("Card Info") { + Toggle("Line count", isOn: $settings.showLineCount) + Toggle("Character count", isOn: $settings.showCharCount) + } + + Section("Timestamp") { + Picker("Show date", selection: $settings.dateDisplayMode) { + ForEach(DateDisplayMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.radioGroup) + + if settings.dateDisplayMode == .afterHours { + Stepper("After \(settings.dateAfterHours)h", + value: $settings.dateAfterHours, in: 1...168) + } + } + } + .formStyle(.grouped) + .frame(width: 300, height: 240) + } +} diff --git a/src/ShelfView.swift b/src/ShelfView.swift index 6123e20..fc10fe6 100644 --- a/src/ShelfView.swift +++ b/src/ShelfView.swift @@ -105,6 +105,50 @@ class ShelfPreviewController: NSObject, QLPreviewPanelDataSource, QLPreviewPanel } } +// MARK: - Palette + +enum Palette { + private static func adaptive( + light: (CGFloat, CGFloat, CGFloat, CGFloat), + dark: (CGFloat, CGFloat, CGFloat, CGFloat) + ) -> Color { + Color(nsColor: NSColor(name: nil) { app in + let d = app.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let c = d ? dark : light + return NSColor(srgbRed: c.0, green: c.1, blue: c.2, alpha: c.3) + }) + } + + static let trayTop = adaptive(light: (0.95, 0.93, 0.90, 0.96), dark: (0.07, 0.05, 0.11, 0.95)) + static let trayBottom = adaptive(light: (0.92, 0.90, 0.87, 0.96), dark: (0.045, 0.03, 0.07, 0.95)) + static let trayEdge = adaptive(light: (0.78, 0.74, 0.82, 0.50), dark: (0.30, 0.24, 0.40, 0.35)) + + static let cardTop = adaptive(light: (0.97, 0.95, 0.98, 1.0), dark: (0.15, 0.12, 0.23, 1.0)) + static let cardBottom = adaptive(light: (0.94, 0.92, 0.95, 1.0), dark: (0.10, 0.08, 0.17, 1.0)) + static let cardEdge = adaptive(light: (0.80, 0.76, 0.85, 1.0), dark: (0.26, 0.21, 0.34, 1.0)) + + static let textPrimary = adaptive(light: (0.16, 0.16, 0.16, 1.0), dark: (1.0, 1.0, 1.0, 0.88)) + static let textSecondary = adaptive(light: (0.42, 0.38, 0.45, 1.0), dark: (1.0, 1.0, 1.0, 0.50)) + static let textTertiary = adaptive(light: (0.60, 0.56, 0.64, 1.0), dark: (1.0, 1.0, 1.0, 0.30)) + + static let shadow = adaptive(light: (0.30, 0.25, 0.40, 0.15), dark: (0.04, 0.02, 0.08, 0.50)) + + static func accent(for ct: ClipContentType) -> Color { + let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + switch ct { + case .text: return isDark + ? Color(.sRGB, red: 0.70, green: 0.53, blue: 0.80) + : Color(.sRGB, red: 0.48, green: 0.25, blue: 0.55) + case .url: return isDark + ? Color(.sRGB, red: 0.36, green: 0.63, blue: 0.75) + : Color(.sRGB, red: 0.18, green: 0.42, blue: 0.58) + case .image: return isDark + ? Color(.sRGB, red: 0.75, green: 0.53, blue: 0.31) + : Color(.sRGB, red: 0.58, green: 0.38, blue: 0.18) + } + } +} + // MARK: - Shelf View struct ShelfView: View { @@ -113,6 +157,9 @@ struct ShelfView: View { @State private var expandedItem: UUID? = nil @State private var expandedSelection: Int? = nil @State private var lastItemCount: Int = 0 + @State private var loadedCount: Int = 40 + + private let bufferPage = 40 private var sortedItems: [ClipItem] { store.items.sorted { a, b in @@ -121,6 +168,10 @@ struct ShelfView: View { } } + private var displayedItems: [ClipItem] { + Array(sortedItems.prefix(loadedCount)) + } + var body: some View { shelf .background( @@ -130,31 +181,44 @@ struct ShelfView: View { onArrow: handleArrow, onDelete: handleDelete, onReturn: handleReturn, - onCopy: handleReturn + onCopy: handleReturn, + onScroll: handleScroll ) ) } private var shelf: some View { - let items = sortedItems + let items = displayedItems + let allItems = sortedItems let trayHeight: CGFloat = 260 return ZStack(alignment: .bottom) { RoundedRectangle(cornerRadius: 12) - .fill(Color.black.opacity(0.7)) + .fill( + LinearGradient( + colors: [Palette.trayTop, Palette.trayBottom], + startPoint: .top, endPoint: .bottom + ) + ) .overlay( RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.white.opacity(0.5), lineWidth: 1) + .strokeBorder( + LinearGradient( + colors: [Palette.trayEdge, Palette.trayEdge.opacity(0.1)], + startPoint: .top, endPoint: .bottom + ), + lineWidth: 1 + ) ) .frame(height: trayHeight) ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { + LazyHStack(spacing: 10) { ForEach(items) { item in - cardGroup(for: item, in: items) + cardGroup(for: item, in: allItems) } } - .padding(.horizontal, 12) + .padding(.horizontal, 14) .padding(.bottom, 10) } .onChange(of: selectedID) { @@ -202,6 +266,7 @@ struct ShelfView: View { showNeighborHint: selectedID == item.id && hasNeighbors && !isExpanded ) .id(item.id) + .contextMenu { clipContextMenu(for: item) } .onTapGesture { if selectedID == item.id { if hasNeighbors { @@ -237,6 +302,7 @@ struct ShelfView: View { private func resetToStart(proxy: ScrollViewProxy) { collapseExpansion() selectedID = nil + loadedCount = bufferPage if let first = sortedItems.first { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(first.id, anchor: .leading) @@ -294,27 +360,65 @@ struct ShelfView: View { return } - let items = sortedItems - guard !items.isEmpty else { return } + let displayed = displayedItems + guard !displayed.isEmpty else { return } if ShelfPreviewController.shared.isVisible { ShelfPreviewController.shared.dismiss() } guard let currentID = selectedID, - let idx = items.firstIndex(where: { $0.id == currentID }) else { - selectedID = items.first?.id + let idx = displayed.firstIndex(where: { $0.id == currentID }) else { + selectedID = displayed.first?.id return } switch direction { case .left: - if idx > 0 { selectedID = items[idx - 1].id } + if idx > 0 { selectedID = displayed[idx - 1].id } case .right: - if idx < items.count - 1 { selectedID = items[idx + 1].id } + if idx < displayed.count - 1 { + selectedID = displayed[idx + 1].id + } + if idx >= loadedCount - 5 { + loadedCount = min(loadedCount + bufferPage, sortedItems.count) + } } } + private func handleScroll(_ delta: CGFloat) { + if delta > 0 { + handleArrow(.left) + } else if delta < 0 { + handleArrow(.right) + } + } + + @ViewBuilder + private func clipContextMenu(for item: ClipItem) -> some View { + Button("Copy") { store.copyToClipboard(item) } + if item.contentType != .image, item.textContent != nil { + Button("Copy as Text") { store.copyTextToClipboard(item) } + } + if item.sourceFilePath != nil { + Button("Copy Path") { store.copyPathToClipboard(item) } + } + Divider() + Button("Edit") { store.editItem(item) } + Divider() + Button(item.isPinned ? "Unpin" : "Pin") { store.togglePin(item) } + Divider() + Button("Delete") { deleteItem(item) } + } + + private func deleteItem(_ item: ClipItem) { + if ShelfPreviewController.shared.currentItem?.id == item.id { + ShelfPreviewController.shared.dismiss() + } + collapseExpansion() + store.delete(item) + } + private func handleDelete() { guard let id = selectedID, let item = sortedItems.first(where: { $0.id == id }) else { return } @@ -365,50 +469,105 @@ struct ClipCardView: View { let item: ClipItem let isSelected: Bool var showNeighborHint: Bool = false + @ObservedObject private var settings = ShelfSettings.shared @State private var isHovered = false + @State private var loadedImage: NSImage? + + private var accent: Color { Palette.accent(for: item.contentType) } + private var hasHistory: Bool { + item.displacedPrev != nil || item.displacedNext != nil + } var body: some View { VStack(alignment: .leading, spacing: 0) { - titleBar - .frame(width: 180) + accent.frame(height: 2) - cardContent - .frame(width: 180, height: 230) - .clipped() + VStack(alignment: .leading, spacing: 0) { + titleBar + .frame(width: 180) - HStack(spacing: 6) { - if item.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 12)) - .foregroundStyle(.orange) + cardContent + .frame(width: 180, height: 228) + .clipped() + + HStack(spacing: 5) { + if item.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 10)) + .foregroundStyle(.orange) + } + Text(settings.formatTimestamp(item.timestamp)) + .font(.system(size: 11)) + .foregroundStyle(Palette.textSecondary) + if settings.showLineCount, item.contentType != .image, + let text = item.textContent { + Text("\(text.components(separatedBy: .newlines).count)L") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Palette.textTertiary) + } + Spacer() + if settings.showCharCount, item.contentType != .image, + let text = item.textContent { + Text("\(text.count)c") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Palette.textTertiary) + } + if showNeighborHint { + Image(systemName: "arrow.left.arrow.right") + .font(.system(size: 10)) + .foregroundStyle(Palette.textSecondary) + } + typeIcon + .font(.system(size: 11)) + .foregroundStyle(accent.opacity(0.5)) } - Text(item.relativeTime) - .font(.system(size: 13)) - .foregroundStyle(.white.opacity(0.7)) - Spacer() - if showNeighborHint { - Image(systemName: "arrow.left.arrow.right") - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.7)) - } - typeIcon - .font(.system(size: 13)) - .foregroundStyle(.white.opacity(0.5)) + .padding(.top, 6) } - .padding(.top, 6) + .padding(8) } - .padding(8) .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.black.opacity(0.86)) + LinearGradient( + colors: [ + isHovered ? Palette.cardTop.opacity(1) : Palette.cardTop.opacity(0.96), + Palette.cardBottom.opacity(0.96) + ], + startPoint: .top, endPoint: .bottom + ) ) + .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelected - ? Color.accentColor - : Color.white.opacity(0.4), lineWidth: isSelected ? 2 : 1) + .strokeBorder( + isSelected + ? accent.opacity(0.7) + : Palette.cardEdge.opacity(isHovered ? 0.7 : 0.4), + lineWidth: isSelected ? 1.5 : 0.5 + ) ) - .onHover { isHovered = $0 } + .overlay(alignment: .topTrailing) { + if hasHistory { + Image(systemName: "square.3.layers.3d") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(accent.opacity(0.6)) + .padding(.top, 10) + .padding(.trailing, 8) + } + } + .compositingGroup() + .shadow( + color: isSelected + ? accent.opacity(0.25) + : Palette.shadow, + radius: isSelected ? 12 : 6, + x: 0, y: isSelected ? 2 : 4 + ) + .shadow( + color: accent.opacity(isSelected ? 0.18 : 0.08), + radius: 16, x: 0, y: 10 + ) + .onHover { hov in + withAnimation(.easeOut(duration: 0.12)) { isHovered = hov } + } } @ViewBuilder @@ -417,14 +576,13 @@ struct ClipCardView: View { if !path.isEmpty { Text(titleAttributed(path)) .lineLimit(1) - .truncationMode(.head) - .shadow(color: .white.opacity(0.6), radius: 3) + .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 6) - .padding(.vertical, 4) + .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.08)) + .fill(accent.opacity(0.08)) ) } } @@ -434,17 +592,17 @@ struct ClipCardView: View { if components.count > 1, let filename = components.last, !filename.isEmpty { let dirPart = String(path.dropLast(filename.count)) var dir = AttributedString(dirPart) - dir.font = .system(size: 13, design: .monospaced) - dir.foregroundColor = .white.opacity(0.7) + dir.font = .system(size: 10, design: .monospaced) + dir.foregroundColor = .init(Palette.textTertiary) var file = AttributedString(filename) - file.font = .system(size: 17, weight: .bold, design: .monospaced) - file.foregroundColor = .white + file.font = .system(size: 13, weight: .semibold, design: .monospaced) + file.foregroundColor = .init(Palette.textPrimary) return dir + file } var attr = AttributedString(path) - attr.font = .system(size: 17, weight: .bold, design: .monospaced) - attr.foregroundColor = .white + attr.font = .system(size: 13, weight: .semibold, design: .monospaced) + attr.foregroundColor = .init(Palette.textPrimary) return attr } @@ -452,39 +610,41 @@ struct ClipCardView: View { private var cardContent: some View { switch item.contentType { case .image: - if let image = item.loadImage() { + if let image = loadedImage { Image(nsImage: image) .resizable() + .interpolation(.medium) .aspectRatio(contentMode: .fill) - .frame(width: 180, height: 230) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .frame(width: 180, height: 228) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { placeholder("photo") + .onAppear { loadedImage = item.loadImage() } } case .url: - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 4) { Image(systemName: "link") - .font(.system(size: 15)) - .foregroundStyle(.blue) + .font(.system(size: 13)) + .foregroundStyle(accent.opacity(0.7)) Text(item.preview) - .font(.system(size: 14, design: .monospaced)) - .lineLimit(7) - .foregroundStyle(.white) + .font(.system(size: 13, design: .monospaced)) + .lineLimit(8) + .foregroundStyle(Palette.textPrimary) } .frame(maxWidth: .infinity, alignment: .leading) case .text: Text(item.preview) - .font(.system(size: 14, design: .monospaced)) + .font(.system(size: 13, design: .monospaced)) .lineLimit(9) - .foregroundStyle(.white) + .foregroundStyle(Palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) } } private func placeholder(_ symbol: String) -> some View { Image(systemName: symbol) - .font(.system(size: 30)) - .foregroundStyle(.white.opacity(0.5)) + .font(.system(size: 28)) + .foregroundStyle(accent.opacity(0.35)) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -504,53 +664,74 @@ struct ClipCardView: View { struct NeighborPeekCard: View { let item: ClipItem let isHighlighted: Bool + @ObservedObject private var settings = ShelfSettings.shared + @State private var loadedImage: NSImage? + + private var accent: Color { Palette.accent(for: item.contentType) } var body: some View { - VStack(alignment: .leading, spacing: 3) { - peekContent - .frame(width: 120, height: 112) - .clipShape(RoundedRectangle(cornerRadius: 4)) + VStack(alignment: .leading, spacing: 0) { + accent.frame(height: 2) - Text(item.relativeTime) - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.7)) + VStack(alignment: .leading, spacing: 3) { + peekContent + .frame(width: 120, height: 108) + .clipShape(RoundedRectangle(cornerRadius: 3)) + + Text(settings.formatTimestamp(item.timestamp)) + .font(.system(size: 10)) + .foregroundStyle(Palette.textSecondary) + } + .padding(6) } - .padding(6) .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.black.opacity(0.86)) + LinearGradient( + colors: [Palette.cardTop.opacity(0.96), Palette.cardBottom.opacity(0.96)], + startPoint: .top, endPoint: .bottom + ) ) + .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder( isHighlighted - ? Color.accentColor - : Color.white.opacity(0.4), - lineWidth: isHighlighted ? 2 : 1 + ? accent.opacity(0.7) + : Palette.cardEdge.opacity(0.4), + lineWidth: isHighlighted ? 1.5 : 0.5 ) ) + .compositingGroup() + .shadow( + color: isHighlighted + ? accent.opacity(0.2) + : Palette.shadow, + radius: isHighlighted ? 8 : 4, + x: 0, y: 3 + ) } @ViewBuilder private var peekContent: some View { switch item.contentType { case .image: - if let image = item.loadImage() { + if let image = loadedImage { Image(nsImage: image) .resizable() + .interpolation(.medium) .aspectRatio(contentMode: .fill) - .frame(width: 120, height: 112) + .frame(width: 120, height: 108) } else { Image(systemName: "photo") - .font(.system(size: 20)) - .foregroundStyle(.white.opacity(0.5)) + .font(.system(size: 18)) + .foregroundStyle(accent.opacity(0.35)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { loadedImage = item.loadImage() } } case .url, .text: Text(item.preview) - .font(.system(size: 12, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) .lineLimit(7) - .foregroundStyle(.white) + .foregroundStyle(Palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -565,6 +746,7 @@ struct KeyCaptureView: NSViewRepresentable { let onDelete: () -> Void let onReturn: () -> Void let onCopy: () -> Void + let onScroll: (CGFloat) -> Void func makeNSView(context: Context) -> KeyCaptureNSView { let view = KeyCaptureNSView() @@ -574,6 +756,7 @@ struct KeyCaptureView: NSViewRepresentable { view.onDelete = onDelete view.onReturn = onReturn view.onCopy = onCopy + view.onScroll = onScroll return view } @@ -584,6 +767,7 @@ struct KeyCaptureView: NSViewRepresentable { nsView.onDelete = onDelete nsView.onReturn = onReturn nsView.onCopy = onCopy + nsView.onScroll = onScroll } } @@ -594,14 +778,56 @@ class KeyCaptureNSView: NSView { var onDelete: (() -> Void)? var onReturn: (() -> Void)? var onCopy: (() -> Void)? + var onScroll: ((CGFloat) -> Void)? + + private var scrollMonitor: Any? + private var scrollAccum: CGFloat = 0 override var acceptsFirstResponder: Bool { true } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + + if let monitor = scrollMonitor { + NSEvent.removeMonitor(monitor) + scrollMonitor = nil + } + + guard window != nil else { return } + DispatchQueue.main.async { [weak self] in self?.window?.makeFirstResponder(self) } + + scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self = self, + let w = self.window, + event.window == w else { return event } + + let dx = event.scrollingDeltaX + let dy = event.scrollingDeltaY + + guard abs(dy) > abs(dx) else { return event } + + self.scrollAccum += dy + + if abs(self.scrollAccum) >= 20 { + self.onScroll?(self.scrollAccum) + self.scrollAccum = 0 + } + + if event.phase == .ended || event.phase == .cancelled { + self.scrollAccum = 0 + } + + return nil + } + } + + deinit { + if let monitor = scrollMonitor { + NSEvent.removeMonitor(monitor) + } } override func keyDown(with event: NSEvent) {