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 # Shelf
A clipboard manager for macOS. Sits in your menu bar, watches what you copy, shows it A clipboard manager for macOS. Hit `Cmd+Shift+V` or click the
all in a floating shelf at the bottom of your screen. Hit `Cmd+Shift+V` or click the
tray icon. tray icon.
You know Paste? It's that, but yours. No subscription, no account, no telemetry. I just didn't feel like this was a thing that should cost money.
Rust backend, Swift frontend, zero dependencies beyond what ships with your Mac. 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 ## What it does
- Monitors your clipboard — text, URLs, images - Monitors your clipboard — text, URLs, images
- Cards show a preview, a title (file path with the filename bolded), and a timestamp - 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 - 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 - Space bar opens native Quick Look on the selected card
- Arrow keys to navigate, Return to paste, Delete to remove - Arrow keys to navigate, Return to paste, Delete to remove
- Starts on login automatically - Starts on login automatically
@ -53,4 +57,6 @@ resources/ Icon SVG, Info.plist
## Author ## 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; bool is_pinned;
int32_t displaced_prev; int32_t displaced_prev;
int32_t displaced_next; int32_t displaced_next;
char *source_path;
} ShelfClip; } ShelfClip;
typedef struct { typedef struct {

View File

@ -7,6 +7,37 @@ APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
CONTENTS="$APP_BUNDLE/Contents" CONTENTS="$APP_BUNDLE/Contents"
ICON_SVG="resources/shelf.svg" 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" rm -rf "$APP_BUNDLE"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
@ -19,7 +50,7 @@ core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --
# Build Swift # Build Swift
swiftc \ swiftc \
-target arm64-apple-macosx14.0 \ -target arm64-apple-macosx14.0 \
-sdk "$(xcrun --show-sdk-path)" \ -sdk "$MACOS_SDK" \
-import-objc-header bridge/shelf_core.h \ -import-objc-header bridge/shelf_core.h \
-L core/target/release \ -L core/target/release \
-lshelf_core \ -lshelf_core \
@ -35,6 +66,6 @@ swiftc \
cp resources/Info.plist "$CONTENTS/" cp resources/Info.plist "$CONTENTS/"
codesign --force --sign - "$APP_BUNDLE" codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE"
echo "Built: $APP_BUNDLE" echo "Built: $APP_BUNDLE"

View File

@ -11,6 +11,7 @@ pub struct Clip {
pub is_pinned: bool, pub is_pinned: bool,
pub displaced_prev: Option<i64>, pub displaced_prev: Option<i64>,
pub displaced_next: Option<i64>, pub displaced_next: Option<i64>,
pub source_path: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@ -60,6 +61,7 @@ impl Clip {
is_pinned: row.get::<_, i32>(6)? != 0, is_pinned: row.get::<_, i32>(6)? != 0,
displaced_prev: row.get(7)?, displaced_prev: row.get(7)?,
displaced_next: row.get(8)?, displaced_next: row.get(8)?,
source_path: row.get(9)?,
}) })
} }
} }

View File

@ -20,6 +20,7 @@ pub struct ShelfClip {
pub is_pinned: bool, pub is_pinned: bool,
pub displaced_prev: i32, pub displaced_prev: i32,
pub displaced_next: i32, pub displaced_next: i32,
pub source_path: *mut c_char,
} }
#[repr(C)] #[repr(C)]
@ -78,6 +79,7 @@ pub extern "C" fn shelf_store_get_all(store: *mut Store) -> ShelfClipList {
is_pinned: c.is_pinned, is_pinned: c.is_pinned,
displaced_prev: c.displaced_prev.map(|v| v as i32).unwrap_or(-1), 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), displaced_next: c.displaced_next.map(|v| v as i32).unwrap_or(-1),
source_path: opt_to_c(&c.source_path),
}) })
.collect(); .collect();
@ -108,6 +110,9 @@ pub extern "C" fn shelf_clip_list_free(list: ShelfClipList) {
if !clip.source_app.is_null() { if !clip.source_app.is_null() {
drop(CString::from_raw(clip.source_app)); 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, is_pinned: ffi.is_pinned,
displaced_prev: if ffi.displaced_prev >= 0 { Some(ffi.displaced_prev as i64) } else { None }, 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 }, 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 { 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_prev INTEGER", []).ok();
conn.execute("ALTER TABLE clips ADD COLUMN displaced_next 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 { Store {
conn, conn,
@ -48,7 +49,7 @@ impl Store {
.conn .conn
.prepare( .prepare(
"SELECT id, timestamp, content_type, text_content, image_path, source_app, is_pinned, "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", FROM clips ORDER BY is_pinned DESC, timestamp DESC",
) )
.unwrap(); .unwrap();
@ -103,8 +104,8 @@ impl Store {
self.conn self.conn
.execute( .execute(
"INSERT OR REPLACE INTO clips "INSERT OR REPLACE INTO clips
(id, timestamp, content_type, text_content, image_path, source_app, is_pinned) (id, timestamp, content_type, text_content, image_path, source_app, is_pinned, source_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![ rusqlite::params![
clip.id, clip.id,
clip.timestamp, clip.timestamp,
@ -113,6 +114,7 @@ impl Store {
image_path, image_path,
clip.source_app, clip.source_app,
clip.is_pinned as i32, clip.is_pinned as i32,
clip.source_path,
], ],
) )
.ok(); .ok();

View File

@ -7,6 +7,37 @@ APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
CONTENTS="$APP_BUNDLE/Contents" CONTENTS="$APP_BUNDLE/Contents"
ICON_SVG="resources/shelf.svg" 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 pkill -f "Shelf.app" 2>/dev/null || true
rm -rf "$APP_BUNDLE" rm -rf "$APP_BUNDLE"
@ -21,7 +52,7 @@ core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --
# Build Swift # Build Swift
swiftc \ swiftc \
-target arm64-apple-macosx14.0 \ -target arm64-apple-macosx14.0 \
-sdk "$(xcrun --show-sdk-path)" \ -sdk "$MACOS_SDK" \
-import-objc-header bridge/shelf_core.h \ -import-objc-header bridge/shelf_core.h \
-L core/target/release \ -L core/target/release \
-lshelf_core \ -lshelf_core \
@ -38,7 +69,7 @@ swiftc \
cp resources/Info.plist "$CONTENTS/" cp resources/Info.plist "$CONTENTS/"
codesign --force --sign - "$APP_BUNDLE" codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE"
echo "Built: $APP_BUNDLE" echo "Built: $APP_BUNDLE"
open "$APP_BUNDLE" open "$APP_BUNDLE"

View File

@ -5,7 +5,7 @@ APP_NAME="Shelf"
bash build.sh 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" rm -rf "/Applications/$APP_NAME.app"
cp -R "build/$APP_NAME.app" "/Applications/$APP_NAME.app" cp -R "build/$APP_NAME.app" "/Applications/$APP_NAME.app"
open "/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 clickMonitor: Any?
private var hotkeyRef: EventHotKeyRef? private var hotkeyRef: EventHotKeyRef?
private var lastHideTime: Date? private var lastHideTime: Date?
private var settingsWindow: NSWindow?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
store = ClipStore() store = ClipStore()
@ -48,6 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let menu = NSMenu() let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Show Shelf", action: #selector(togglePanel), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Show Shelf", action: #selector(togglePanel), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator()) 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(title: "Clear All", action: #selector(clearAll), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit Shelf", action: #selector(quit), keyEquivalent: "q")) 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() { @objc private func clearAll() {
store.clearAll() store.clearAll()
} }

View File

@ -22,6 +22,7 @@ struct ClipItem: Identifiable {
var rawImageData: Data? var rawImageData: Data?
var displacedPrev: Int? var displacedPrev: Int?
var displacedNext: Int? var displacedNext: Int?
var sourceFilePath: String?
var preview: String { var preview: String {
switch contentType { 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? { var sourceAppName: String? {
guard let bundleID = sourceApp, guard let bundleID = sourceApp,
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { 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? { func loadImage() -> NSImage? {
guard let path = imagePath else { let key = id.uuidString as NSString
if let data = rawImageData { if let cached = Self.thumbCache.object(forKey: key) {
return NSImage(data: data) 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 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, sourceApp: c.source_app != nil ? String(cString: c.source_app) : nil,
isPinned: c.is_pinned, isPinned: c.is_pinned,
displacedPrev: c.displaced_prev >= 0 ? Int(c.displaced_prev) : nil, 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 items = loaded
@ -47,10 +48,12 @@ class ClipStore: ObservableObject {
let idStr = strdup(item.id.uuidString) let idStr = strdup(item.id.uuidString)
let textStr = item.textContent.flatMap { strdup($0) } let textStr = item.textContent.flatMap { strdup($0) }
let appStr = item.sourceApp.flatMap { strdup($0) } let appStr = item.sourceApp.flatMap { strdup($0) }
let srcStr = item.sourceFilePath.flatMap { strdup($0) }
defer { defer {
free(idStr) free(idStr)
free(textStr) free(textStr)
free(appStr) free(appStr)
free(srcStr)
} }
var clip = ShelfClip( var clip = ShelfClip(
@ -62,7 +65,8 @@ class ClipStore: ObservableObject {
source_app: appStr, source_app: appStr,
is_pinned: item.isPinned, is_pinned: item.isPinned,
displaced_prev: -1, displaced_prev: -1,
displaced_next: -1 displaced_next: -1,
source_path: srcStr
) )
if let data = item.rawImageData { if let data = item.rawImageData {
@ -95,21 +99,53 @@ class ClipStore: ObservableObject {
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
switch item.contentType { if let path = item.sourceFilePath,
case .text: FileManager.default.fileExists(atPath: path) {
pb.setString(item.textContent ?? "", forType: .string) pb.writeObjects([NSURL(fileURLWithPath: path)])
case .url: } else {
pb.setString(item.textContent ?? "", forType: .string) switch item.contentType {
pb.setString(item.textContent ?? "", forType: .URL) case .text:
case .image: pb.setString(item.textContent ?? "", forType: .string)
if let image = item.loadImage() { case .url:
pb.writeObjects([image]) 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) 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() { func clearAll() {
shelf_store_clear_all(storePtr) shelf_store_clear_all(storePtr)
items.removeAll() items.removeAll()

View File

@ -42,6 +42,60 @@ class PasteboardMonitor {
let sourceApp = NSWorkspace.shared.frontmostApplication?.bundleIdentifier 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) { if let imageData = pb.data(forType: .tiff) ?? pb.data(forType: .png) {
let item = ClipItem( let item = ClipItem(
id: UUID(), timestamp: now, contentType: .image, 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 // MARK: - Shelf View
struct ShelfView: View { struct ShelfView: View {
@ -113,6 +157,9 @@ struct ShelfView: View {
@State private var expandedItem: UUID? = nil @State private var expandedItem: UUID? = nil
@State private var expandedSelection: Int? = nil @State private var expandedSelection: Int? = nil
@State private var lastItemCount: Int = 0 @State private var lastItemCount: Int = 0
@State private var loadedCount: Int = 40
private let bufferPage = 40
private var sortedItems: [ClipItem] { private var sortedItems: [ClipItem] {
store.items.sorted { a, b in 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 { var body: some View {
shelf shelf
.background( .background(
@ -130,31 +181,44 @@ struct ShelfView: View {
onArrow: handleArrow, onArrow: handleArrow,
onDelete: handleDelete, onDelete: handleDelete,
onReturn: handleReturn, onReturn: handleReturn,
onCopy: handleReturn onCopy: handleReturn,
onScroll: handleScroll
) )
) )
} }
private var shelf: some View { private var shelf: some View {
let items = sortedItems let items = displayedItems
let allItems = sortedItems
let trayHeight: CGFloat = 260 let trayHeight: CGFloat = 260
return ZStack(alignment: .bottom) { return ZStack(alignment: .bottom) {
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.7)) .fill(
LinearGradient(
colors: [Palette.trayTop, Palette.trayBottom],
startPoint: .top, endPoint: .bottom
)
)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) 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) .frame(height: trayHeight)
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { LazyHStack(spacing: 10) {
ForEach(items) { item in ForEach(items) { item in
cardGroup(for: item, in: items) cardGroup(for: item, in: allItems)
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 14)
.padding(.bottom, 10) .padding(.bottom, 10)
} }
.onChange(of: selectedID) { .onChange(of: selectedID) {
@ -202,6 +266,7 @@ struct ShelfView: View {
showNeighborHint: selectedID == item.id && hasNeighbors && !isExpanded showNeighborHint: selectedID == item.id && hasNeighbors && !isExpanded
) )
.id(item.id) .id(item.id)
.contextMenu { clipContextMenu(for: item) }
.onTapGesture { .onTapGesture {
if selectedID == item.id { if selectedID == item.id {
if hasNeighbors { if hasNeighbors {
@ -237,6 +302,7 @@ struct ShelfView: View {
private func resetToStart(proxy: ScrollViewProxy) { private func resetToStart(proxy: ScrollViewProxy) {
collapseExpansion() collapseExpansion()
selectedID = nil selectedID = nil
loadedCount = bufferPage
if let first = sortedItems.first { if let first = sortedItems.first {
withAnimation(.easeOut(duration: 0.15)) { withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(first.id, anchor: .leading) proxy.scrollTo(first.id, anchor: .leading)
@ -294,27 +360,65 @@ struct ShelfView: View {
return return
} }
let items = sortedItems let displayed = displayedItems
guard !items.isEmpty else { return } guard !displayed.isEmpty else { return }
if ShelfPreviewController.shared.isVisible { if ShelfPreviewController.shared.isVisible {
ShelfPreviewController.shared.dismiss() ShelfPreviewController.shared.dismiss()
} }
guard let currentID = selectedID, guard let currentID = selectedID,
let idx = items.firstIndex(where: { $0.id == currentID }) else { let idx = displayed.firstIndex(where: { $0.id == currentID }) else {
selectedID = items.first?.id selectedID = displayed.first?.id
return return
} }
switch direction { switch direction {
case .left: case .left:
if idx > 0 { selectedID = items[idx - 1].id } if idx > 0 { selectedID = displayed[idx - 1].id }
case .right: 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() { private func handleDelete() {
guard let id = selectedID, guard let id = selectedID,
let item = sortedItems.first(where: { $0.id == id }) else { return } let item = sortedItems.first(where: { $0.id == id }) else { return }
@ -365,50 +469,105 @@ struct ClipCardView: View {
let item: ClipItem let item: ClipItem
let isSelected: Bool let isSelected: Bool
var showNeighborHint: Bool = false var showNeighborHint: Bool = false
@ObservedObject private var settings = ShelfSettings.shared
@State private var isHovered = false @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 { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
titleBar accent.frame(height: 2)
.frame(width: 180)
cardContent VStack(alignment: .leading, spacing: 0) {
.frame(width: 180, height: 230) titleBar
.clipped() .frame(width: 180)
HStack(spacing: 6) { cardContent
if item.isPinned { .frame(width: 180, height: 228)
Image(systemName: "pin.fill") .clipped()
.font(.system(size: 12))
.foregroundStyle(.orange) 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) .padding(.top, 6)
.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(8)
} }
.padding(8)
.background( .background(
RoundedRectangle(cornerRadius: 10) LinearGradient(
.fill(Color.black.opacity(0.86)) 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( .overlay(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.strokeBorder(isSelected .strokeBorder(
? Color.accentColor isSelected
: Color.white.opacity(0.4), lineWidth: isSelected ? 2 : 1) ? 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 @ViewBuilder
@ -417,14 +576,13 @@ struct ClipCardView: View {
if !path.isEmpty { if !path.isEmpty {
Text(titleAttributed(path)) Text(titleAttributed(path))
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) .truncationMode(.middle)
.shadow(color: .white.opacity(0.6), radius: 3)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 3)
.background( .background(
RoundedRectangle(cornerRadius: 4) 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 { if components.count > 1, let filename = components.last, !filename.isEmpty {
let dirPart = String(path.dropLast(filename.count)) let dirPart = String(path.dropLast(filename.count))
var dir = AttributedString(dirPart) var dir = AttributedString(dirPart)
dir.font = .system(size: 13, design: .monospaced) dir.font = .system(size: 10, design: .monospaced)
dir.foregroundColor = .white.opacity(0.7) dir.foregroundColor = .init(Palette.textTertiary)
var file = AttributedString(filename) var file = AttributedString(filename)
file.font = .system(size: 17, weight: .bold, design: .monospaced) file.font = .system(size: 13, weight: .semibold, design: .monospaced)
file.foregroundColor = .white file.foregroundColor = .init(Palette.textPrimary)
return dir + file return dir + file
} }
var attr = AttributedString(path) var attr = AttributedString(path)
attr.font = .system(size: 17, weight: .bold, design: .monospaced) attr.font = .system(size: 13, weight: .semibold, design: .monospaced)
attr.foregroundColor = .white attr.foregroundColor = .init(Palette.textPrimary)
return attr return attr
} }
@ -452,39 +610,41 @@ struct ClipCardView: View {
private var cardContent: some View { private var cardContent: some View {
switch item.contentType { switch item.contentType {
case .image: case .image:
if let image = item.loadImage() { if let image = loadedImage {
Image(nsImage: image) Image(nsImage: image)
.resizable() .resizable()
.interpolation(.medium)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 180, height: 230) .frame(width: 180, height: 228)
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 4))
} else { } else {
placeholder("photo") placeholder("photo")
.onAppear { loadedImage = item.loadImage() }
} }
case .url: case .url:
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 4) {
Image(systemName: "link") Image(systemName: "link")
.font(.system(size: 15)) .font(.system(size: 13))
.foregroundStyle(.blue) .foregroundStyle(accent.opacity(0.7))
Text(item.preview) Text(item.preview)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 13, design: .monospaced))
.lineLimit(7) .lineLimit(8)
.foregroundStyle(.white) .foregroundStyle(Palette.textPrimary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
case .text: case .text:
Text(item.preview) Text(item.preview)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 13, design: .monospaced))
.lineLimit(9) .lineLimit(9)
.foregroundStyle(.white) .foregroundStyle(Palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
private func placeholder(_ symbol: String) -> some View { private func placeholder(_ symbol: String) -> some View {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 30)) .font(.system(size: 28))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(accent.opacity(0.35))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ -504,53 +664,74 @@ struct ClipCardView: View {
struct NeighborPeekCard: View { struct NeighborPeekCard: View {
let item: ClipItem let item: ClipItem
let isHighlighted: Bool 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 { var body: some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 0) {
peekContent accent.frame(height: 2)
.frame(width: 120, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 4))
Text(item.relativeTime) VStack(alignment: .leading, spacing: 3) {
.font(.system(size: 12)) peekContent
.foregroundStyle(.white.opacity(0.7)) .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( .background(
RoundedRectangle(cornerRadius: 6) LinearGradient(
.fill(Color.black.opacity(0.86)) colors: [Palette.cardTop.opacity(0.96), Palette.cardBottom.opacity(0.96)],
startPoint: .top, endPoint: .bottom
)
) )
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.strokeBorder( .strokeBorder(
isHighlighted isHighlighted
? Color.accentColor ? accent.opacity(0.7)
: Color.white.opacity(0.4), : Palette.cardEdge.opacity(0.4),
lineWidth: isHighlighted ? 2 : 1 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 @ViewBuilder
private var peekContent: some View { private var peekContent: some View {
switch item.contentType { switch item.contentType {
case .image: case .image:
if let image = item.loadImage() { if let image = loadedImage {
Image(nsImage: image) Image(nsImage: image)
.resizable() .resizable()
.interpolation(.medium)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 120, height: 112) .frame(width: 120, height: 108)
} else { } else {
Image(systemName: "photo") Image(systemName: "photo")
.font(.system(size: 20)) .font(.system(size: 18))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(accent.opacity(0.35))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { loadedImage = item.loadImage() }
} }
case .url, .text: case .url, .text:
Text(item.preview) Text(item.preview)
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.lineLimit(7) .lineLimit(7)
.foregroundStyle(.white) .foregroundStyle(Palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@ -565,6 +746,7 @@ struct KeyCaptureView: NSViewRepresentable {
let onDelete: () -> Void let onDelete: () -> Void
let onReturn: () -> Void let onReturn: () -> Void
let onCopy: () -> Void let onCopy: () -> Void
let onScroll: (CGFloat) -> Void
func makeNSView(context: Context) -> KeyCaptureNSView { func makeNSView(context: Context) -> KeyCaptureNSView {
let view = KeyCaptureNSView() let view = KeyCaptureNSView()
@ -574,6 +756,7 @@ struct KeyCaptureView: NSViewRepresentable {
view.onDelete = onDelete view.onDelete = onDelete
view.onReturn = onReturn view.onReturn = onReturn
view.onCopy = onCopy view.onCopy = onCopy
view.onScroll = onScroll
return view return view
} }
@ -584,6 +767,7 @@ struct KeyCaptureView: NSViewRepresentable {
nsView.onDelete = onDelete nsView.onDelete = onDelete
nsView.onReturn = onReturn nsView.onReturn = onReturn
nsView.onCopy = onCopy nsView.onCopy = onCopy
nsView.onScroll = onScroll
} }
} }
@ -594,14 +778,56 @@ class KeyCaptureNSView: NSView {
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
var onReturn: (() -> Void)? var onReturn: (() -> Void)?
var onCopy: (() -> Void)? var onCopy: (() -> Void)?
var onScroll: ((CGFloat) -> Void)?
private var scrollMonitor: Any?
private var scrollAccum: CGFloat = 0
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
override func viewDidMoveToWindow() { override func viewDidMoveToWindow() {
super.viewDidMoveToWindow() super.viewDidMoveToWindow()
if let monitor = scrollMonitor {
NSEvent.removeMonitor(monitor)
scrollMonitor = nil
}
guard window != nil else { return }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.window?.makeFirstResponder(self) 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) { override func keyDown(with event: NSEvent) {