Compare commits

..

No commits in common. "main" and "1.0" have entirely different histories.
main ... 1.0

15 changed files with 123 additions and 683 deletions

3
.gitignore vendored
View File

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

View File

@ -1,22 +1,18 @@
# Shelf # Shelf
A clipboard manager for macOS. Hit `Cmd+Shift+V` or click the 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
tray icon. tray icon.
I just didn't feel like this was a thing that should cost money. You know Paste? It's that, but yours. No subscription, no account, no telemetry.
Now it doesn't anymore. Rust backend, Swift frontend, zero dependencies beyond what ships with your Mac.
## 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 (click twice - not exactly double click, just... twice.) - Tracks where items were displaced from, so you can peek at old neighbors
- 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
@ -57,6 +53,4 @@ resources/ Icon SVG, Info.plist
## Author ## Author
[pszsh](https://else-if.org) - My Half-Assed blog. [pszsh](https://else-if.org)
Email: [jess@else-if.org](mailto:jess@else-if.org)

View File

@ -17,7 +17,6 @@ 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,37 +7,6 @@ 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"
@ -50,7 +19,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 "$MACOS_SDK" \ -sdk "$(xcrun --show-sdk-path)" \
-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 \
@ -66,6 +35,6 @@ swiftc \
cp resources/Info.plist "$CONTENTS/" cp resources/Info.plist "$CONTENTS/"
codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE" codesign --force --sign - "$APP_BUNDLE"
echo "Built: $APP_BUNDLE" echo "Built: $APP_BUNDLE"

View File

@ -11,7 +11,6 @@ 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)]
@ -61,7 +60,6 @@ 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,7 +20,6 @@ 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)]
@ -79,7 +78,6 @@ 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();
@ -110,9 +108,6 @@ 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));
}
} }
} }
} }
@ -137,7 +132,6 @@ 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,7 +35,6 @@ 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,
@ -49,7 +48,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, source_path displaced_prev, displaced_next
FROM clips ORDER BY is_pinned DESC, timestamp DESC", FROM clips ORDER BY is_pinned DESC, timestamp DESC",
) )
.unwrap(); .unwrap();
@ -104,8 +103,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, source_path) (id, timestamp, content_type, text_content, image_path, source_app, is_pinned)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![ rusqlite::params![
clip.id, clip.id,
clip.timestamp, clip.timestamp,
@ -114,7 +113,6 @@ 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,37 +7,6 @@ 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"
@ -52,7 +21,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 "$MACOS_SDK" \ -sdk "$(xcrun --show-sdk-path)" \
-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 \
@ -69,7 +38,7 @@ swiftc \
cp resources/Info.plist "$CONTENTS/" cp resources/Info.plist "$CONTENTS/"
codesign --force --sign "${MACOS_SIGNING_IDENTITY:--}" "$APP_BUNDLE" codesign --force --sign - "$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 && sleep 1 || true killall "$APP_NAME" 2>/dev/null || 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,7 +11,6 @@ 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()
@ -49,7 +48,6 @@ 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"))
@ -102,25 +100,6 @@ 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,7 +22,6 @@ 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 {
@ -35,6 +34,14 @@ 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 {
@ -52,41 +59,13 @@ struct ClipItem: Identifiable {
} }
} }
private static let thumbCache = NSCache<NSString, NSImage>()
func loadImage() -> NSImage? { func loadImage() -> NSImage? {
let key = id.uuidString as NSString guard let path = imagePath else {
if let cached = Self.thumbCache.object(forKey: key) { if let data = rawImageData {
return cached return NSImage(data: data)
} }
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,8 +37,7 @@ 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
@ -48,12 +47,10 @@ 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(
@ -65,8 +62,7 @@ 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 {
@ -99,53 +95,21 @@ class ClipStore: ObservableObject {
let pb = NSPasteboard.general let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
if let path = item.sourceFilePath, switch item.contentType {
FileManager.default.fileExists(atPath: path) { case .text:
pb.writeObjects([NSURL(fileURLWithPath: path)]) pb.setString(item.textContent ?? "", forType: .string)
} else { case .url:
switch item.contentType { pb.setString(item.textContent ?? "", forType: .string)
case .text: pb.setString(item.textContent ?? "", forType: .URL)
pb.setString(item.textContent ?? "", forType: .string) case .image:
case .url: if let image = item.loadImage() {
pb.setString(item.textContent ?? "", forType: .string) pb.writeObjects([image])
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,60 +42,6 @@ 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,

View File

@ -1,120 +0,0 @@
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,50 +105,6 @@ 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 {
@ -157,9 +113,6 @@ 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
@ -168,10 +121,6 @@ struct ShelfView: View {
} }
} }
private var displayedItems: [ClipItem] {
Array(sortedItems.prefix(loadedCount))
}
var body: some View { var body: some View {
shelf shelf
.background( .background(
@ -181,44 +130,31 @@ 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 = displayedItems let items = sortedItems
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( .fill(Color.black.opacity(0.7))
LinearGradient(
colors: [Palette.trayTop, Palette.trayBottom],
startPoint: .top, endPoint: .bottom
)
)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.strokeBorder( .strokeBorder(Color.white.opacity(0.5), lineWidth: 1)
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) {
LazyHStack(spacing: 10) { HStack(spacing: 8) {
ForEach(items) { item in ForEach(items) { item in
cardGroup(for: item, in: allItems) cardGroup(for: item, in: items)
} }
} }
.padding(.horizontal, 14) .padding(.horizontal, 12)
.padding(.bottom, 10) .padding(.bottom, 10)
} }
.onChange(of: selectedID) { .onChange(of: selectedID) {
@ -266,7 +202,6 @@ 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 {
@ -302,7 +237,6 @@ 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)
@ -360,65 +294,27 @@ struct ShelfView: View {
return return
} }
let displayed = displayedItems let items = sortedItems
guard !displayed.isEmpty else { return } guard !items.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 = displayed.firstIndex(where: { $0.id == currentID }) else { let idx = items.firstIndex(where: { $0.id == currentID }) else {
selectedID = displayed.first?.id selectedID = items.first?.id
return return
} }
switch direction { switch direction {
case .left: case .left:
if idx > 0 { selectedID = displayed[idx - 1].id } if idx > 0 { selectedID = items[idx - 1].id }
case .right: case .right:
if idx < displayed.count - 1 { if idx < items.count - 1 { selectedID = items[idx + 1].id }
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 }
@ -469,105 +365,50 @@ 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) {
accent.frame(height: 2) titleBar
.frame(width: 180)
VStack(alignment: .leading, spacing: 0) { cardContent
titleBar .frame(width: 180, height: 230)
.frame(width: 180) .clipped()
cardContent HStack(spacing: 6) {
.frame(width: 180, height: 228) if item.isPinned {
.clipped() Image(systemName: "pin.fill")
.font(.system(size: 12))
HStack(spacing: 5) { .foregroundStyle(.orange)
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))
} }
.padding(.top, 6) 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(8) .padding(.top, 6)
} }
.padding(8)
.background( .background(
LinearGradient( RoundedRectangle(cornerRadius: 10)
colors: [ .fill(Color.black.opacity(0.86))
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( .strokeBorder(isSelected
isSelected ? Color.accentColor
? accent.opacity(0.7) : Color.white.opacity(0.4), lineWidth: isSelected ? 2 : 1)
: Palette.cardEdge.opacity(isHovered ? 0.7 : 0.4),
lineWidth: isSelected ? 1.5 : 0.5
)
) )
.overlay(alignment: .topTrailing) { .onHover { isHovered = $0 }
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
@ -576,13 +417,14 @@ struct ClipCardView: View {
if !path.isEmpty { if !path.isEmpty {
Text(titleAttributed(path)) Text(titleAttributed(path))
.lineLimit(1) .lineLimit(1)
.truncationMode(.middle) .truncationMode(.head)
.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, 3) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(accent.opacity(0.08)) .fill(Color.white.opacity(0.08))
) )
} }
} }
@ -592,17 +434,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: 10, design: .monospaced) dir.font = .system(size: 13, design: .monospaced)
dir.foregroundColor = .init(Palette.textTertiary) dir.foregroundColor = .white.opacity(0.7)
var file = AttributedString(filename) var file = AttributedString(filename)
file.font = .system(size: 13, weight: .semibold, design: .monospaced) file.font = .system(size: 17, weight: .bold, design: .monospaced)
file.foregroundColor = .init(Palette.textPrimary) file.foregroundColor = .white
return dir + file return dir + file
} }
var attr = AttributedString(path) var attr = AttributedString(path)
attr.font = .system(size: 13, weight: .semibold, design: .monospaced) attr.font = .system(size: 17, weight: .bold, design: .monospaced)
attr.foregroundColor = .init(Palette.textPrimary) attr.foregroundColor = .white
return attr return attr
} }
@ -610,41 +452,39 @@ 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 = loadedImage { if let image = item.loadImage() {
Image(nsImage: image) Image(nsImage: image)
.resizable() .resizable()
.interpolation(.medium)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 180, height: 228) .frame(width: 180, height: 230)
.clipShape(RoundedRectangle(cornerRadius: 4)) .clipShape(RoundedRectangle(cornerRadius: 6))
} else { } else {
placeholder("photo") placeholder("photo")
.onAppear { loadedImage = item.loadImage() }
} }
case .url: case .url:
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 3) {
Image(systemName: "link") Image(systemName: "link")
.font(.system(size: 13)) .font(.system(size: 15))
.foregroundStyle(accent.opacity(0.7)) .foregroundStyle(.blue)
Text(item.preview) Text(item.preview)
.font(.system(size: 13, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.lineLimit(8) .lineLimit(7)
.foregroundStyle(Palette.textPrimary) .foregroundStyle(.white)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
case .text: case .text:
Text(item.preview) Text(item.preview)
.font(.system(size: 13, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.lineLimit(9) .lineLimit(9)
.foregroundStyle(Palette.textPrimary) .foregroundStyle(.white)
.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: 28)) .font(.system(size: 30))
.foregroundStyle(accent.opacity(0.35)) .foregroundStyle(.white.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@ -664,74 +504,53 @@ 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: 0) { VStack(alignment: .leading, spacing: 3) {
accent.frame(height: 2) peekContent
.frame(width: 120, height: 112)
.clipShape(RoundedRectangle(cornerRadius: 4))
VStack(alignment: .leading, spacing: 3) { Text(item.relativeTime)
peekContent .font(.system(size: 12))
.frame(width: 120, height: 108) .foregroundStyle(.white.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 3))
Text(settings.formatTimestamp(item.timestamp))
.font(.system(size: 10))
.foregroundStyle(Palette.textSecondary)
}
.padding(6)
} }
.padding(6)
.background( .background(
LinearGradient( RoundedRectangle(cornerRadius: 6)
colors: [Palette.cardTop.opacity(0.96), Palette.cardBottom.opacity(0.96)], .fill(Color.black.opacity(0.86))
startPoint: .top, endPoint: .bottom
)
) )
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.strokeBorder( .strokeBorder(
isHighlighted isHighlighted
? accent.opacity(0.7) ? Color.accentColor
: Palette.cardEdge.opacity(0.4), : Color.white.opacity(0.4),
lineWidth: isHighlighted ? 1.5 : 0.5 lineWidth: isHighlighted ? 2 : 1
) )
) )
.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 = loadedImage { if let image = item.loadImage() {
Image(nsImage: image) Image(nsImage: image)
.resizable() .resizable()
.interpolation(.medium)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 120, height: 108) .frame(width: 120, height: 112)
} else { } else {
Image(systemName: "photo") Image(systemName: "photo")
.font(.system(size: 18)) .font(.system(size: 20))
.foregroundStyle(accent.opacity(0.35)) .foregroundStyle(.white.opacity(0.5))
.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: 11, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.lineLimit(7) .lineLimit(7)
.foregroundStyle(Palette.textPrimary) .foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@ -746,7 +565,6 @@ 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()
@ -756,7 +574,6 @@ 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
} }
@ -767,7 +584,6 @@ struct KeyCaptureView: NSViewRepresentable {
nsView.onDelete = onDelete nsView.onDelete = onDelete
nsView.onReturn = onReturn nsView.onReturn = onReturn
nsView.onCopy = onCopy nsView.onCopy = onCopy
nsView.onScroll = onScroll
} }
} }
@ -778,56 +594,14 @@ 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) {