Compare commits

...

4 Commits
1.0 ... main

Author SHA1 Message Date
pszsh c9c572325d fixed a bug with the build script which prior to had a specific version of the swift toolchain as a strict requirment 2026-03-26 18:14:24 -07:00
pszsh b1be256c70 update readme 2026-03-17 15:47:27 -07:00
pszsh 37cd2d4d5d update readme 2026-03-17 15:35:12 -07:00
pszsh faa2e9389b Added automatic switching, user configurable settings and lots of
performance improvements, ie, caching, thumbnailing, sliding history
buffer. Bugfixes too.
2026-03-15 10:49:40 -07:00
15 changed files with 683 additions and 123 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
target/
build/
.DS_Store*

View File

@ -1,18 +1,22 @@
# Shelf
A clipboard manager for macOS. Sits in your menu bar, watches what you copy, shows it
all in a floating shelf at the bottom of your screen. Hit `Cmd+Shift+V` or click the
A clipboard manager for macOS. Hit `Cmd+Shift+V` or click the
tray icon.
You know Paste? It's that, but yours. No subscription, no account, no telemetry.
Rust backend, Swift frontend, zero dependencies beyond what ships with your Mac.
I just didn't feel like this was a thing that should cost money.
Now it doesn't anymore.
## Preview
<img width="1934" height="386" alt="B39A5D5D-60D0-4B77-AA18-BACEC7DA2C42" src="https://github.com/user-attachments/assets/5031fe8d-3b71-4577-8714-4fde2e16fc59" />
## What it does
- Monitors your clipboard — text, URLs, images
- Cards show a preview, a title (file path with the filename bolded), and a timestamp
- Re-copying something moves it to the front instead of duplicating it
- Tracks where items were displaced from, so you can peek at old neighbors
- Tracks where items were displaced from, so you can peek at old neighbors (click twice - not exactly double click, just... twice.)
- Space bar opens native Quick Look on the selected card
- Arrow keys to navigate, Return to paste, Delete to remove
- Starts on login automatically
@ -53,4 +57,6 @@ resources/ Icon SVG, Info.plist
## Author
[pszsh](https://else-if.org)
[pszsh](https://else-if.org) - My Half-Assed blog.
Email: [jess@else-if.org](mailto:jess@else-if.org)

View File

@ -17,6 +17,7 @@ typedef struct {
bool is_pinned;
int32_t displaced_prev;
int32_t displaced_next;
char *source_path;
} ShelfClip;
typedef struct {

View File

@ -7,6 +7,37 @@ APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
CONTENTS="$APP_BUNDLE/Contents"
ICON_SVG="resources/shelf.svg"
find_sdk() {
local tmp="/tmp/shelf_sdk_check_$$.swift"
printf 'let _=0\n' > "$tmp"
local default_sdk
default_sdk="$(xcrun --show-sdk-path 2>/dev/null)"
if swiftc -sdk "$default_sdk" -typecheck "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "$default_sdk"
return
fi
local sdk_dir
sdk_dir="$(dirname "$default_sdk")"
for sdk in $(ls -rd "$sdk_dir"/MacOSX[0-9]*.sdk 2>/dev/null); do
if swiftc -sdk "$sdk" -typecheck "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "$sdk"
return
fi
done
rm -f "$tmp"
echo "Error: no macOS SDK compatible with installed Swift compiler" >&2
exit 1
}
MACOS_SDK="$(find_sdk)"
rm -rf "$APP_BUNDLE"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
@ -19,7 +50,7 @@ core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --
# Build Swift
swiftc \
-target arm64-apple-macosx14.0 \
-sdk "$(xcrun --show-sdk-path)" \
-sdk "$MACOS_SDK" \
-import-objc-header bridge/shelf_core.h \
-L core/target/release \
-lshelf_core \
@ -35,6 +66,6 @@ swiftc \
cp resources/Info.plist "$CONTENTS/"
codesign --force --sign - "$APP_BUNDLE"
codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE"
echo "Built: $APP_BUNDLE"

View File

@ -11,6 +11,7 @@ pub struct Clip {
pub is_pinned: bool,
pub displaced_prev: Option<i64>,
pub displaced_next: Option<i64>,
pub source_path: Option<String>,
}
#[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)?,
})
}
}

View File

@ -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 {

View File

@ -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();

View File

@ -7,6 +7,37 @@ APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
CONTENTS="$APP_BUNDLE/Contents"
ICON_SVG="resources/shelf.svg"
find_sdk() {
local tmp="/tmp/shelf_sdk_check_$$.swift"
printf 'let _=0\n' > "$tmp"
local default_sdk
default_sdk="$(xcrun --show-sdk-path 2>/dev/null)"
if swiftc -sdk "$default_sdk" -typecheck "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "$default_sdk"
return
fi
local sdk_dir
sdk_dir="$(dirname "$default_sdk")"
for sdk in $(ls -rd "$sdk_dir"/MacOSX[0-9]*.sdk 2>/dev/null); do
if swiftc -sdk "$sdk" -typecheck "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "$sdk"
return
fi
done
rm -f "$tmp"
echo "Error: no macOS SDK compatible with installed Swift compiler" >&2
exit 1
}
MACOS_SDK="$(find_sdk)"
pkill -f "Shelf.app" 2>/dev/null || true
rm -rf "$APP_BUNDLE"
@ -21,7 +52,7 @@ core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --
# Build Swift
swiftc \
-target arm64-apple-macosx14.0 \
-sdk "$(xcrun --show-sdk-path)" \
-sdk "$MACOS_SDK" \
-import-objc-header bridge/shelf_core.h \
-L core/target/release \
-lshelf_core \
@ -38,7 +69,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"

View File

@ -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"

View File

@ -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()
}

View File

@ -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<NSString, NSImage>()
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
}
}

View File

@ -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()

View File

@ -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<String> = [
"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<String> = [
"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,

120
src/Settings.swift Normal file
View File

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

View File

@ -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) {