Added automatic switching, user configurable settings and lots of
performance improvements, ie, caching, thumbnailing, sliding history buffer. Bugfixes too.
This commit is contained in:
parent
c57df890aa
commit
faa2e9389b
|
|
@ -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 {
|
||||||
|
|
|
||||||
2
build.sh
2
build.sh
|
|
@ -35,6 +35,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"
|
||||||
|
|
|
||||||
|
|
@ -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)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
2
debug.sh
2
debug.sh
|
|
@ -38,7 +38,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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue