Init' it!
This commit is contained in:
commit
83be80f620
|
|
@ -0,0 +1,12 @@
|
||||||
|
This is free to use, without conditions.
|
||||||
|
|
||||||
|
There is no licence here on purpose. Individuals, students, hobbyists — take what
|
||||||
|
you need, make it yours, don't think twice. You'd flatter me.
|
||||||
|
|
||||||
|
The absence of a licence is deliberate. A licence is a legal surface. Words can be
|
||||||
|
reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is
|
||||||
|
harder to exploit than language. If a company wants to use this, the lack of explicit
|
||||||
|
permission makes it just inconvenient enough to matter.
|
||||||
|
|
||||||
|
This won't change the world. But it shifts the balance, even slightly, away from the
|
||||||
|
system that co-opts open work for closed profit. That's enough for me.
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#ifndef SHELF_CORE_H
|
||||||
|
#define SHELF_CORE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
typedef struct ShelfStore ShelfStore;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char *id;
|
||||||
|
double timestamp;
|
||||||
|
uint8_t content_type;
|
||||||
|
char *text_content;
|
||||||
|
char *image_path;
|
||||||
|
char *source_app;
|
||||||
|
bool is_pinned;
|
||||||
|
int32_t displaced_prev;
|
||||||
|
int32_t displaced_next;
|
||||||
|
} ShelfClip;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
ShelfClip *clips;
|
||||||
|
size_t count;
|
||||||
|
} ShelfClipList;
|
||||||
|
|
||||||
|
ShelfStore *shelf_store_new(const char *support_dir, int max_items);
|
||||||
|
void shelf_store_free(ShelfStore *store);
|
||||||
|
|
||||||
|
ShelfClipList shelf_store_get_all(ShelfStore *store);
|
||||||
|
void shelf_clip_list_free(ShelfClipList list);
|
||||||
|
|
||||||
|
char *shelf_store_add(ShelfStore *store, const ShelfClip *clip,
|
||||||
|
const uint8_t *image_data, size_t image_len);
|
||||||
|
void shelf_store_delete(ShelfStore *store, const char *id);
|
||||||
|
bool shelf_store_toggle_pin(ShelfStore *store, const char *id);
|
||||||
|
void shelf_store_clear_all(ShelfStore *store);
|
||||||
|
|
||||||
|
void shelf_string_free(char *s);
|
||||||
|
|
||||||
|
bool shelf_generate_icns(const char *svg_path, const char *output_path,
|
||||||
|
bool nearest_neighbor);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="Shelf"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
|
||||||
|
CONTENTS="$APP_BUNDLE/Contents"
|
||||||
|
ICON_SVG="resources/shelf.svg"
|
||||||
|
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||||
|
|
||||||
|
# Build Rust core
|
||||||
|
(cd core && cargo build --release)
|
||||||
|
|
||||||
|
# Generate icon
|
||||||
|
core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --nearest-neighbor
|
||||||
|
|
||||||
|
# Build Swift
|
||||||
|
swiftc \
|
||||||
|
-target arm64-apple-macosx14.0 \
|
||||||
|
-sdk "$(xcrun --show-sdk-path)" \
|
||||||
|
-import-objc-header bridge/shelf_core.h \
|
||||||
|
-L core/target/release \
|
||||||
|
-lshelf_core \
|
||||||
|
-framework Cocoa \
|
||||||
|
-framework SwiftUI \
|
||||||
|
-framework Carbon \
|
||||||
|
-framework UniformTypeIdentifiers \
|
||||||
|
-framework ServiceManagement \
|
||||||
|
-framework Quartz \
|
||||||
|
-O \
|
||||||
|
-o "$CONTENTS/MacOS/$APP_NAME" \
|
||||||
|
src/*.swift
|
||||||
|
|
||||||
|
cp resources/Info.plist "$CONTENTS/"
|
||||||
|
|
||||||
|
codesign --force --sign - "$APP_BUNDLE"
|
||||||
|
|
||||||
|
echo "Built: $APP_BUNDLE"
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_maths"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||||
|
dependencies = [
|
||||||
|
"libm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "euclid"
|
||||||
|
version = "0.22.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fontconfig-parser"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||||
|
dependencies = [
|
||||||
|
"roxmltree",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fontdb"
|
||||||
|
version = "0.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37be9fc20d966be438cd57a45767f73349477fb0f85ce86e000557f787298afb"
|
||||||
|
dependencies = [
|
||||||
|
"fontconfig-parser",
|
||||||
|
"log",
|
||||||
|
"memmap2",
|
||||||
|
"slotmap",
|
||||||
|
"tinyvec",
|
||||||
|
"ttf-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kurbo"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"euclid",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.183"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pico-args"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.17.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "resvg"
|
||||||
|
version = "0.43.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7314563c59c7ce31c18e23ad3dd092c37b928a0fa4e1c0a1a6504351ab411d1"
|
||||||
|
dependencies = [
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"log",
|
||||||
|
"pico-args",
|
||||||
|
"rgb",
|
||||||
|
"svgtypes",
|
||||||
|
"tiny-skia",
|
||||||
|
"usvg",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustybuzz"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"bytemuck",
|
||||||
|
"core_maths",
|
||||||
|
"log",
|
||||||
|
"smallvec",
|
||||||
|
"ttf-parser",
|
||||||
|
"unicode-bidi-mirroring",
|
||||||
|
"unicode-ccc",
|
||||||
|
"unicode-properties",
|
||||||
|
"unicode-script",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shelf-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"resvg",
|
||||||
|
"rusqlite",
|
||||||
|
"tiny-skia",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simplecss"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slotmap"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||||
|
dependencies = [
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strict-num"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
dependencies = [
|
||||||
|
"float-cmp",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "svgtypes"
|
||||||
|
version = "0.15.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
|
||||||
|
dependencies = [
|
||||||
|
"kurbo",
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"bytemuck",
|
||||||
|
"cfg-if",
|
||||||
|
"log",
|
||||||
|
"png",
|
||||||
|
"tiny-skia-path",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia-path"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"bytemuck",
|
||||||
|
"strict-num",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec_macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ttf-parser"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a"
|
||||||
|
dependencies = [
|
||||||
|
"core_maths",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-bidi"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-bidi-mirroring"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ccc"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-properties"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-script"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-vo"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usvg"
|
||||||
|
version = "0.43.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6803057b5cbb426e9fb8ce2216f3a9b4ca1dd2c705ba3cbebc13006e437735fd"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"data-url",
|
||||||
|
"flate2",
|
||||||
|
"fontdb",
|
||||||
|
"imagesize",
|
||||||
|
"kurbo",
|
||||||
|
"log",
|
||||||
|
"pico-args",
|
||||||
|
"roxmltree",
|
||||||
|
"rustybuzz",
|
||||||
|
"simplecss",
|
||||||
|
"siphasher",
|
||||||
|
"strict-num",
|
||||||
|
"svgtypes",
|
||||||
|
"tiny-skia-path",
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-script",
|
||||||
|
"unicode-vo",
|
||||||
|
"xmlwriter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xmlwriter"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.4.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "shelf-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["staticlib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "shelf-icon"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
resvg = "0.43"
|
||||||
|
tiny-skia = "0.11"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
panic = "abort"
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
use rusqlite::Row;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Clip {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: f64,
|
||||||
|
pub content_type: ContentType,
|
||||||
|
pub text_content: Option<String>,
|
||||||
|
pub image_path: Option<String>,
|
||||||
|
pub source_app: Option<String>,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub displaced_prev: Option<i64>,
|
||||||
|
pub displaced_next: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum ContentType {
|
||||||
|
Text = 0,
|
||||||
|
Url = 1,
|
||||||
|
Image = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentType {
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"url" => Self::Url,
|
||||||
|
"image" => Self::Image,
|
||||||
|
_ => Self::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Text => "text",
|
||||||
|
Self::Url => "url",
|
||||||
|
Self::Image => "image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(v: u8) -> Self {
|
||||||
|
match v {
|
||||||
|
1 => Self::Url,
|
||||||
|
2 => Self::Image,
|
||||||
|
_ => Self::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clip {
|
||||||
|
pub fn from_row(row: &Row) -> rusqlite::Result<Self> {
|
||||||
|
let ct_str: String = row.get(2)?;
|
||||||
|
Ok(Clip {
|
||||||
|
id: row.get(0)?,
|
||||||
|
timestamp: row.get(1)?,
|
||||||
|
content_type: ContentType::from_str(&ct_str),
|
||||||
|
text_content: row.get(3)?,
|
||||||
|
image_path: row.get(4)?,
|
||||||
|
source_app: row.get(5)?,
|
||||||
|
is_pinned: row.get::<_, i32>(6)? != 0,
|
||||||
|
displaced_prev: row.get(7)?,
|
||||||
|
displaced_next: row.get(8)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
const ICNS_SIZES: &[(u32, [u8; 4])] = &[
|
||||||
|
(16, *b"icp4"),
|
||||||
|
(32, *b"icp5"),
|
||||||
|
(64, *b"icp6"),
|
||||||
|
(128, *b"ic07"),
|
||||||
|
(256, *b"ic08"),
|
||||||
|
(512, *b"ic09"),
|
||||||
|
(1024, *b"ic10"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn generate_icns(svg_path: &str, output_path: &str, nearest_neighbor: bool) -> bool {
|
||||||
|
let svg_data = match fs::read(svg_path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let opt = resvg::usvg::Options::default();
|
||||||
|
let tree = match resvg::usvg::Tree::from_data(&svg_data, &opt) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let orig_size = tree.size();
|
||||||
|
let ow = orig_size.width() as u32;
|
||||||
|
let oh = orig_size.height() as u32;
|
||||||
|
|
||||||
|
let mut base_pixmap = match tiny_skia::Pixmap::new(ow, oh) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
resvg::render(
|
||||||
|
&tree,
|
||||||
|
tiny_skia::Transform::identity(),
|
||||||
|
&mut base_pixmap.as_mut(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut icns_data: Vec<u8> = Vec::new();
|
||||||
|
icns_data.extend_from_slice(b"icns");
|
||||||
|
icns_data.extend_from_slice(&[0u8; 4]);
|
||||||
|
|
||||||
|
for &(target_size, ostype) in ICNS_SIZES {
|
||||||
|
let png_data = if target_size == ow && target_size == oh {
|
||||||
|
match base_pixmap.encode_png() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
} else if nearest_neighbor {
|
||||||
|
let mut target = match tiny_skia::Pixmap::new(target_size, target_size) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
nn_scale(
|
||||||
|
base_pixmap.data(),
|
||||||
|
ow,
|
||||||
|
oh,
|
||||||
|
target.data_mut(),
|
||||||
|
target_size,
|
||||||
|
target_size,
|
||||||
|
);
|
||||||
|
match target.encode_png() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut target = match tiny_skia::Pixmap::new(target_size, target_size) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let sx = target_size as f32 / orig_size.width();
|
||||||
|
let sy = target_size as f32 / orig_size.height();
|
||||||
|
resvg::render(
|
||||||
|
&tree,
|
||||||
|
tiny_skia::Transform::from_scale(sx, sy),
|
||||||
|
&mut target.as_mut(),
|
||||||
|
);
|
||||||
|
match target.encode_png() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_size = (png_data.len() + 8) as u32;
|
||||||
|
icns_data.extend_from_slice(&ostype);
|
||||||
|
icns_data.extend_from_slice(&entry_size.to_be_bytes());
|
||||||
|
icns_data.extend_from_slice(&png_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = icns_data.len() as u32;
|
||||||
|
icns_data[4..8].copy_from_slice(&total_size.to_be_bytes());
|
||||||
|
|
||||||
|
fs::write(output_path, &icns_data).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nn_scale(src: &[u8], sw: u32, sh: u32, dst: &mut [u8], dw: u32, dh: u32) {
|
||||||
|
for dy in 0..dh {
|
||||||
|
for dx in 0..dw {
|
||||||
|
let sx = (dx * sw / dw).min(sw - 1);
|
||||||
|
let sy = (dy * sh / dh).min(sh - 1);
|
||||||
|
let si = ((sy * sw + sx) * 4) as usize;
|
||||||
|
let di = ((dy * dw + dx) * 4) as usize;
|
||||||
|
dst[di..di + 4].copy_from_slice(&src[si..si + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
pub mod clip;
|
||||||
|
pub mod icon;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use clip::{Clip, ContentType};
|
||||||
|
use store::Store;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ShelfClip {
|
||||||
|
pub id: *mut c_char,
|
||||||
|
pub timestamp: f64,
|
||||||
|
pub content_type: u8,
|
||||||
|
pub text_content: *mut c_char,
|
||||||
|
pub image_path: *mut c_char,
|
||||||
|
pub source_app: *mut c_char,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub displaced_prev: i32,
|
||||||
|
pub displaced_next: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ShelfClipList {
|
||||||
|
pub clips: *mut ShelfClip,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_c_string(s: &str) -> *mut c_char {
|
||||||
|
CString::new(s).unwrap_or_default().into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_to_c(s: &Option<String>) -> *mut c_char {
|
||||||
|
match s {
|
||||||
|
Some(s) => to_c_string(s),
|
||||||
|
None => ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn from_c(p: *const c_char) -> Option<String> {
|
||||||
|
if p.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(CStr::from_ptr(p).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_new(support_dir: *const c_char, max_items: i32) -> *mut Store {
|
||||||
|
let dir = unsafe { CStr::from_ptr(support_dir).to_string_lossy() };
|
||||||
|
Box::into_raw(Box::new(Store::new(&dir, max_items as usize)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_free(store: *mut Store) {
|
||||||
|
if !store.is_null() {
|
||||||
|
unsafe {
|
||||||
|
drop(Box::from_raw(store));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_get_all(store: *mut Store) -> ShelfClipList {
|
||||||
|
let store = unsafe { &*store };
|
||||||
|
let clips = store.get_all();
|
||||||
|
|
||||||
|
let mut ffi_clips: Vec<ShelfClip> = clips
|
||||||
|
.iter()
|
||||||
|
.map(|c| ShelfClip {
|
||||||
|
id: to_c_string(&c.id),
|
||||||
|
timestamp: c.timestamp,
|
||||||
|
content_type: c.content_type as u8,
|
||||||
|
text_content: opt_to_c(&c.text_content),
|
||||||
|
image_path: opt_to_c(&c.image_path),
|
||||||
|
source_app: opt_to_c(&c.source_app),
|
||||||
|
is_pinned: c.is_pinned,
|
||||||
|
displaced_prev: c.displaced_prev.map(|v| v as i32).unwrap_or(-1),
|
||||||
|
displaced_next: c.displaced_next.map(|v| v as i32).unwrap_or(-1),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count = ffi_clips.len();
|
||||||
|
let ptr = ffi_clips.as_mut_ptr();
|
||||||
|
std::mem::forget(ffi_clips);
|
||||||
|
|
||||||
|
ShelfClipList { clips: ptr, count }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_clip_list_free(list: ShelfClipList) {
|
||||||
|
if list.clips.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let clips = unsafe { Vec::from_raw_parts(list.clips, list.count, list.count) };
|
||||||
|
for clip in clips {
|
||||||
|
unsafe {
|
||||||
|
if !clip.id.is_null() {
|
||||||
|
drop(CString::from_raw(clip.id));
|
||||||
|
}
|
||||||
|
if !clip.text_content.is_null() {
|
||||||
|
drop(CString::from_raw(clip.text_content));
|
||||||
|
}
|
||||||
|
if !clip.image_path.is_null() {
|
||||||
|
drop(CString::from_raw(clip.image_path));
|
||||||
|
}
|
||||||
|
if !clip.source_app.is_null() {
|
||||||
|
drop(CString::from_raw(clip.source_app));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_add(
|
||||||
|
store: *mut Store,
|
||||||
|
clip: *const ShelfClip,
|
||||||
|
image_data: *const u8,
|
||||||
|
image_len: usize,
|
||||||
|
) -> *mut c_char {
|
||||||
|
let store = unsafe { &*store };
|
||||||
|
let ffi = unsafe { &*clip };
|
||||||
|
|
||||||
|
let rust_clip = Clip {
|
||||||
|
id: unsafe { from_c(ffi.id) }.unwrap_or_default(),
|
||||||
|
timestamp: ffi.timestamp,
|
||||||
|
content_type: ContentType::from_u8(ffi.content_type),
|
||||||
|
text_content: unsafe { from_c(ffi.text_content) },
|
||||||
|
image_path: unsafe { from_c(ffi.image_path) },
|
||||||
|
source_app: unsafe { from_c(ffi.source_app) },
|
||||||
|
is_pinned: ffi.is_pinned,
|
||||||
|
displaced_prev: if ffi.displaced_prev >= 0 { Some(ffi.displaced_prev as i64) } else { None },
|
||||||
|
displaced_next: if ffi.displaced_next >= 0 { Some(ffi.displaced_next as i64) } else { None },
|
||||||
|
};
|
||||||
|
|
||||||
|
let img = if !image_data.is_null() && image_len > 0 {
|
||||||
|
Some(unsafe { std::slice::from_raw_parts(image_data, image_len) })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match store.add(&rust_clip, img) {
|
||||||
|
Some(path) => to_c_string(&path),
|
||||||
|
None => ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_delete(store: *mut Store, id: *const c_char) {
|
||||||
|
let store = unsafe { &*store };
|
||||||
|
let id = unsafe { CStr::from_ptr(id).to_string_lossy() };
|
||||||
|
store.delete(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_toggle_pin(store: *mut Store, id: *const c_char) -> bool {
|
||||||
|
let store = unsafe { &*store };
|
||||||
|
let id = unsafe { CStr::from_ptr(id).to_string_lossy() };
|
||||||
|
store.toggle_pin(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_store_clear_all(store: *mut Store) {
|
||||||
|
let store = unsafe { &*store };
|
||||||
|
store.clear_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_string_free(s: *mut c_char) {
|
||||||
|
if !s.is_null() {
|
||||||
|
unsafe {
|
||||||
|
drop(CString::from_raw(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn shelf_generate_icns(
|
||||||
|
svg_path: *const c_char,
|
||||||
|
output_path: *const c_char,
|
||||||
|
nearest_neighbor: bool,
|
||||||
|
) -> bool {
|
||||||
|
let svg = unsafe { CStr::from_ptr(svg_path).to_string_lossy() };
|
||||||
|
let out = unsafe { CStr::from_ptr(output_path).to_string_lossy() };
|
||||||
|
icon::generate_icns(&svg, &out, nearest_neighbor)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 3 {
|
||||||
|
eprintln!("Usage: shelf-icon <svg> <output.icns> [--nearest-neighbor]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let nn = args.iter().any(|a| a == "--nearest-neighbor");
|
||||||
|
if shelf_core::icon::generate_icns(&args[1], &args[2], nn) {
|
||||||
|
println!("Generated: {}", args[2]);
|
||||||
|
} else {
|
||||||
|
eprintln!("Failed to generate ICNS");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
use crate::clip::{Clip, ContentType};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
conn: Connection,
|
||||||
|
image_dir: PathBuf,
|
||||||
|
max_items: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn new(support_dir: &str, max_items: usize) -> Self {
|
||||||
|
let support = Path::new(support_dir);
|
||||||
|
let image_dir = support.join("images");
|
||||||
|
fs::create_dir_all(support).ok();
|
||||||
|
fs::create_dir_all(&image_dir).ok();
|
||||||
|
|
||||||
|
let db_path = support.join("clips.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("failed to open database");
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clips (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timestamp REAL NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
text_content TEXT,
|
||||||
|
image_path TEXT,
|
||||||
|
source_app TEXT,
|
||||||
|
is_pinned INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_timestamp ON clips(timestamp DESC);",
|
||||||
|
)
|
||||||
|
.expect("failed to create schema");
|
||||||
|
|
||||||
|
conn.execute("ALTER TABLE clips ADD COLUMN displaced_prev INTEGER", []).ok();
|
||||||
|
conn.execute("ALTER TABLE clips ADD COLUMN displaced_next INTEGER", []).ok();
|
||||||
|
|
||||||
|
Store {
|
||||||
|
conn,
|
||||||
|
image_dir,
|
||||||
|
max_items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all(&self) -> Vec<Clip> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, timestamp, content_type, text_content, image_path, source_app, is_pinned,
|
||||||
|
displaced_prev, displaced_next
|
||||||
|
FROM clips ORDER BY is_pinned DESC, timestamp DESC",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
stmt.query_map([], |row| Clip::from_row(row))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&self, clip: &Clip, image_data: Option<&[u8]>) -> Option<String> {
|
||||||
|
if let Some(existing_id) = self.find_duplicate(clip) {
|
||||||
|
let ordered: Vec<String> = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT id FROM clips ORDER BY is_pinned DESC, timestamp DESC")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| row.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(pos) = ordered.iter().position(|id| *id == existing_id) {
|
||||||
|
if pos == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let prev: Option<i64> = Some(pos as i64);
|
||||||
|
let next: Option<i64> =
|
||||||
|
if pos + 1 < ordered.len() { Some((pos + 1) as i64) } else { None };
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE clips SET timestamp = ?1, source_app = ?2,
|
||||||
|
displaced_prev = ?3, displaced_next = ?4 WHERE id = ?5",
|
||||||
|
rusqlite::params![clip.timestamp, clip.source_app, prev, next, existing_id],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_path = if clip.content_type == ContentType::Image {
|
||||||
|
image_data.and_then(|data| {
|
||||||
|
let filename = format!("{}.png", clip.id);
|
||||||
|
let path = self.image_dir.join(&filename);
|
||||||
|
fs::write(&path, data).ok()?;
|
||||||
|
Some(path.to_string_lossy().into_owned())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO clips
|
||||||
|
(id, timestamp, content_type, text_content, image_path, source_app, is_pinned)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
rusqlite::params![
|
||||||
|
clip.id,
|
||||||
|
clip.timestamp,
|
||||||
|
clip.content_type.as_str(),
|
||||||
|
clip.text_content,
|
||||||
|
image_path,
|
||||||
|
clip.source_app,
|
||||||
|
clip.is_pinned as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
self.prune_excess();
|
||||||
|
image_path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: &str) {
|
||||||
|
if let Ok(path) = self.conn.query_row(
|
||||||
|
"SELECT image_path FROM clips WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
) {
|
||||||
|
if let Some(p) = path {
|
||||||
|
fs::remove_file(&p).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM clips WHERE id = ?1", [id])
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_pin(&self, id: &str) -> bool {
|
||||||
|
let current: bool = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT is_pinned FROM clips WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| Ok(row.get::<_, i32>(0)? != 0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let new_val = !current;
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE clips SET is_pinned = ?1 WHERE id = ?2",
|
||||||
|
rusqlite::params![new_val as i32, id],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
new_val
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_all(&self) {
|
||||||
|
if let Ok(entries) = fs::read_dir(&self.image_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
fs::remove_file(entry.path()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.conn.execute("DELETE FROM clips", []).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_duplicate(&self, clip: &Clip) -> Option<String> {
|
||||||
|
if clip.content_type == ContentType::Image {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = clip.text_content.as_ref()?;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM clips WHERE content_type = ?1 AND text_content = ?2 LIMIT 1",
|
||||||
|
rusqlite::params![clip.content_type.as_str(), text],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_excess(&self) {
|
||||||
|
let count: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM clips WHERE is_pinned = 0",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if (count as usize) <= self.max_items {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let excess = count as usize - self.max_items;
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, image_path FROM clips
|
||||||
|
WHERE is_pinned = 0 ORDER BY timestamp ASC LIMIT ?1",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let to_remove: Vec<(String, Option<String>)> = stmt
|
||||||
|
.query_map([excess as i64], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (id, path) in &to_remove {
|
||||||
|
if let Some(p) = path {
|
||||||
|
fs::remove_file(p).ok();
|
||||||
|
}
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM clips WHERE id = ?1", [id])
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="Shelf"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
|
||||||
|
CONTENTS="$APP_BUNDLE/Contents"
|
||||||
|
ICON_SVG="resources/shelf.svg"
|
||||||
|
|
||||||
|
pkill -f "Shelf.app" 2>/dev/null || true
|
||||||
|
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||||
|
|
||||||
|
# Build Rust core
|
||||||
|
(cd core && cargo build --release)
|
||||||
|
|
||||||
|
# Generate icon
|
||||||
|
core/target/release/shelf-icon "$ICON_SVG" "$CONTENTS/Resources/AppIcon.icns" --nearest-neighbor
|
||||||
|
|
||||||
|
# Build Swift
|
||||||
|
swiftc \
|
||||||
|
-target arm64-apple-macosx14.0 \
|
||||||
|
-sdk "$(xcrun --show-sdk-path)" \
|
||||||
|
-import-objc-header bridge/shelf_core.h \
|
||||||
|
-L core/target/release \
|
||||||
|
-lshelf_core \
|
||||||
|
-framework Cocoa \
|
||||||
|
-framework SwiftUI \
|
||||||
|
-framework Carbon \
|
||||||
|
-framework UniformTypeIdentifiers \
|
||||||
|
-framework ServiceManagement \
|
||||||
|
-framework Quartz \
|
||||||
|
-g -Onone \
|
||||||
|
-D DEBUG \
|
||||||
|
-o "$CONTENTS/MacOS/$APP_NAME" \
|
||||||
|
src/*.swift
|
||||||
|
|
||||||
|
cp resources/Info.plist "$CONTENTS/"
|
||||||
|
|
||||||
|
codesign --force --sign - "$APP_BUNDLE"
|
||||||
|
|
||||||
|
echo "Built: $APP_BUNDLE"
|
||||||
|
open "$APP_BUNDLE"
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="Shelf"
|
||||||
|
|
||||||
|
bash build.sh
|
||||||
|
|
||||||
|
killall "$APP_NAME" 2>/dev/null || true
|
||||||
|
rm -rf "/Applications/$APP_NAME.app"
|
||||||
|
cp -R "build/$APP_NAME.app" "/Applications/$APP_NAME.app"
|
||||||
|
open "/Applications/$APP_NAME.app"
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.elseif.shelf</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Shelf</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Shelf</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Shelf</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56.31 61"><defs><style>.cls-1,.cls-2{fill:#fff;}.cls-2{stroke:#000;stroke-miterlimit:10;}</style></defs><rect class="cls-1" x="2.06" y="6.76" width="53.74" height="53.74"/><path d="M55.31,7.26V60H2.56V7.26H55.31m1-1H1.56V61H56.31V6.26Z"/><rect class="cls-1" x="20.58" y="23.71" width="15.14" height="15.8"/><path d="M35.47,24v15.3H20.83V24H35.47m.5-.5H20.33v16.3H36V23.46Z"/><path d="M45.11,15.89V17H14.33V15.89H45.11m.25-.25H14.08v1.57H45.36V15.64Z"/><rect class="cls-1" x="11.07" y="50.18" width="4.44" height="1.31"/><path d="M15.39,50.3v1.07H11.2V50.3h4.19m.25-.25H11v1.57h4.69V50.05Z"/><rect class="cls-1" width="23.46" height="12.51"/><rect class="cls-2" x="6.01" y="5.82" width="17.38" height="6.26" transform="translate(-2.19 5.64) rotate(-20.26)"/><path d="M48.24,42.48v1.06H14.33V42.48H48.24m.25-.25H14.08v1.56H48.49V42.23Z"/><rect class="cls-1" x="9.51" y="45.48" width="16.96" height="1.31"/><path d="M26.34,45.61v1.06H9.63V45.61H26.34m.25-.25H9.38v1.56H26.59V45.36Z"/><rect class="cls-1" x="22.02" y="18.89" width="16.95" height="1.31"/><path d="M38.85,19v1.06H22.15V19h16.7m.25-.25H21.9v1.56H39.1V18.77Z"/><rect class="cls-1" x="15.77" y="53.3" width="31.03" height="1.31"/><path d="M46.67,53.43v1.06H15.89V53.43H46.67m.25-.25H15.64v1.56H46.92V53.18Z"/><path class="cls-2" d="M1.6,12.4l5.56,0Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,143 @@
|
||||||
|
import Cocoa
|
||||||
|
import SwiftUI
|
||||||
|
import Carbon
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var statusItem: NSStatusItem!
|
||||||
|
private var panel: ShelfPanel!
|
||||||
|
private var store: ClipStore!
|
||||||
|
private var monitor: PasteboardMonitor!
|
||||||
|
private var clickMonitor: Any?
|
||||||
|
private var hotkeyRef: EventHotKeyRef?
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
store = ClipStore()
|
||||||
|
monitor = PasteboardMonitor()
|
||||||
|
monitor.onNewClip = { [weak self] item in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.store.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start()
|
||||||
|
|
||||||
|
setupPanel()
|
||||||
|
setupStatusItem()
|
||||||
|
registerHotkey()
|
||||||
|
registerLoginItem()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .dismissShelfPanel, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.hidePanel()
|
||||||
|
self?.monitor.syncChangeCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Item
|
||||||
|
|
||||||
|
private func setupStatusItem() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
|
if let button = statusItem.button {
|
||||||
|
button.image = NSImage(systemSymbolName: "clipboard.fill",
|
||||||
|
accessibilityDescription: "Shelf")
|
||||||
|
button.action = #selector(statusItemClicked)
|
||||||
|
button.target = self
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
menu.addItem(NSMenuItem(title: "Show Shelf", action: #selector(togglePanel), keyEquivalent: ""))
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Clear All", action: #selector(clearAll), keyEquivalent: ""))
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
menu.addItem(NSMenuItem(title: "Quit Shelf", action: #selector(quit), keyEquivalent: "q"))
|
||||||
|
statusItem.menu = menu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func statusItemClicked() {
|
||||||
|
togglePanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panel
|
||||||
|
|
||||||
|
private func setupPanel() {
|
||||||
|
panel = ShelfPanel(contentRect: NSRect(x: 0, y: 0, width: 800, height: 340))
|
||||||
|
let hostingView = NSHostingView(rootView: ShelfView(store: store))
|
||||||
|
panel.contentView = hostingView
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func togglePanel() {
|
||||||
|
if panel.isVisible {
|
||||||
|
hidePanel()
|
||||||
|
} else {
|
||||||
|
showPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPanel() {
|
||||||
|
panel.showAtBottom()
|
||||||
|
|
||||||
|
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) {
|
||||||
|
[weak self] event in
|
||||||
|
guard let self = self, self.panel.isVisible else { return }
|
||||||
|
let screenPoint = NSEvent.mouseLocation
|
||||||
|
if !self.panel.frame.contains(screenPoint) {
|
||||||
|
self.hidePanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hidePanel() {
|
||||||
|
ShelfPreviewController.shared.dismiss()
|
||||||
|
panel.cancelOperation(nil)
|
||||||
|
if let m = clickMonitor {
|
||||||
|
NSEvent.removeMonitor(m)
|
||||||
|
clickMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func clearAll() {
|
||||||
|
store.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quit() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login Item
|
||||||
|
|
||||||
|
private func registerLoginItem() {
|
||||||
|
try? SMAppService.mainApp.register()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global Hotkey (Cmd+Shift+V)
|
||||||
|
|
||||||
|
private func registerHotkey() {
|
||||||
|
var eventType = EventTypeSpec(
|
||||||
|
eventClass: OSType(kEventClassKeyboard),
|
||||||
|
eventKind: UInt32(kEventHotKeyPressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
InstallEventHandler(
|
||||||
|
GetApplicationEventTarget(),
|
||||||
|
{ (_, event, userData) -> OSStatus in
|
||||||
|
guard let userData = userData else { return OSStatus(eventNotHandledErr) }
|
||||||
|
let delegate = Unmanaged<AppDelegate>.fromOpaque(userData).takeUnretainedValue()
|
||||||
|
DispatchQueue.main.async { delegate.togglePanel() }
|
||||||
|
return noErr
|
||||||
|
},
|
||||||
|
1, &eventType, selfPtr, nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let hotkeyID = EventHotKeyID(signature: 0x53484C46, id: 1)
|
||||||
|
RegisterEventHotKey(
|
||||||
|
UInt32(kVK_ANSI_V),
|
||||||
|
UInt32(cmdKey | shiftKey),
|
||||||
|
hotkeyID,
|
||||||
|
GetApplicationEventTarget(),
|
||||||
|
0,
|
||||||
|
&hotkeyRef
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
enum ClipContentType: UInt8 {
|
||||||
|
case text = 0
|
||||||
|
case url = 1
|
||||||
|
case image = 2
|
||||||
|
|
||||||
|
static func from(_ value: UInt8) -> ClipContentType {
|
||||||
|
ClipContentType(rawValue: value) ?? .text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClipItem: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let timestamp: Date
|
||||||
|
let contentType: ClipContentType
|
||||||
|
let textContent: String?
|
||||||
|
var imagePath: String?
|
||||||
|
let sourceApp: String?
|
||||||
|
var isPinned: Bool
|
||||||
|
var rawImageData: Data?
|
||||||
|
var displacedPrev: Int?
|
||||||
|
var displacedNext: Int?
|
||||||
|
|
||||||
|
var preview: String {
|
||||||
|
switch contentType {
|
||||||
|
case .text:
|
||||||
|
return String((textContent ?? "").prefix(300))
|
||||||
|
case .url:
|
||||||
|
return textContent ?? ""
|
||||||
|
case .image:
|
||||||
|
return "Image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativeTime: String {
|
||||||
|
let interval = Date().timeIntervalSince(timestamp)
|
||||||
|
if interval < 60 { return "now" }
|
||||||
|
if interval < 3600 { return "\(Int(interval / 60))m" }
|
||||||
|
if interval < 86400 { return "\(Int(interval / 3600))h" }
|
||||||
|
return "\(Int(interval / 86400))d"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceAppName: String? {
|
||||||
|
guard let bundleID = sourceApp,
|
||||||
|
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return FileManager.default.displayName(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var titlePath: String {
|
||||||
|
switch contentType {
|
||||||
|
case .text, .url:
|
||||||
|
return (textContent ?? "").components(separatedBy: .newlines).first ?? ""
|
||||||
|
case .image:
|
||||||
|
return imagePath ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImage() -> NSImage? {
|
||||||
|
guard let path = imagePath else {
|
||||||
|
if let data = rawImageData {
|
||||||
|
return NSImage(data: data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NSImage(contentsOfFile: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class ClipStore: ObservableObject {
|
||||||
|
@Published var items: [ClipItem] = []
|
||||||
|
private var storePtr: OpaquePointer
|
||||||
|
|
||||||
|
init(maxItems: Int = 500) {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
let dir = base.appendingPathComponent("Shelf").path
|
||||||
|
storePtr = shelf_store_new(dir, Int32(maxItems))
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
shelf_store_free(storePtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() {
|
||||||
|
let list = shelf_store_get_all(storePtr)
|
||||||
|
defer { shelf_clip_list_free(list) }
|
||||||
|
|
||||||
|
var loaded: [ClipItem] = []
|
||||||
|
guard let clips = list.clips else {
|
||||||
|
items = loaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let c = clips[i]
|
||||||
|
loaded.append(ClipItem(
|
||||||
|
id: UUID(uuidString: c.id != nil ? String(cString: c.id) : "") ?? UUID(),
|
||||||
|
timestamp: Date(timeIntervalSince1970: c.timestamp),
|
||||||
|
contentType: ClipContentType.from(c.content_type),
|
||||||
|
textContent: c.text_content != nil ? String(cString: c.text_content) : nil,
|
||||||
|
imagePath: c.image_path != nil ? String(cString: c.image_path) : nil,
|
||||||
|
sourceApp: c.source_app != nil ? String(cString: c.source_app) : nil,
|
||||||
|
isPinned: c.is_pinned,
|
||||||
|
displacedPrev: c.displaced_prev >= 0 ? Int(c.displaced_prev) : nil,
|
||||||
|
displacedNext: c.displaced_next >= 0 ? Int(c.displaced_next) : nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
items = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ item: ClipItem) {
|
||||||
|
let idStr = strdup(item.id.uuidString)
|
||||||
|
let textStr = item.textContent.flatMap { strdup($0) }
|
||||||
|
let appStr = item.sourceApp.flatMap { strdup($0) }
|
||||||
|
defer {
|
||||||
|
free(idStr)
|
||||||
|
free(textStr)
|
||||||
|
free(appStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var clip = ShelfClip(
|
||||||
|
id: idStr,
|
||||||
|
timestamp: item.timestamp.timeIntervalSince1970,
|
||||||
|
content_type: item.contentType.rawValue,
|
||||||
|
text_content: textStr,
|
||||||
|
image_path: nil,
|
||||||
|
source_app: appStr,
|
||||||
|
is_pinned: item.isPinned,
|
||||||
|
displaced_prev: -1,
|
||||||
|
displaced_next: -1
|
||||||
|
)
|
||||||
|
|
||||||
|
if let data = item.rawImageData {
|
||||||
|
data.withUnsafeBytes { buf in
|
||||||
|
let ptr = buf.baseAddress?.assumingMemoryBound(to: UInt8.self)
|
||||||
|
let result = shelf_store_add(storePtr, &clip, ptr, data.count)
|
||||||
|
if result != nil { shelf_string_free(result) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let result = shelf_store_add(storePtr, &clip, nil, 0)
|
||||||
|
if result != nil { shelf_string_free(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ item: ClipItem) {
|
||||||
|
item.id.uuidString.withCString { shelf_store_delete(storePtr, $0) }
|
||||||
|
items.removeAll { $0.id == item.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePin(_ item: ClipItem) {
|
||||||
|
let newPinned = item.id.uuidString.withCString { shelf_store_toggle_pin(storePtr, $0) }
|
||||||
|
if let idx = items.firstIndex(where: { $0.id == item.id }) {
|
||||||
|
items[idx].isPinned = newPinned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToClipboard(_ item: ClipItem) {
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
|
||||||
|
switch item.contentType {
|
||||||
|
case .text:
|
||||||
|
pb.setString(item.textContent ?? "", forType: .string)
|
||||||
|
case .url:
|
||||||
|
pb.setString(item.textContent ?? "", forType: .string)
|
||||||
|
pb.setString(item.textContent ?? "", forType: .URL)
|
||||||
|
case .image:
|
||||||
|
if let image = item.loadImage() {
|
||||||
|
pb.writeObjects([image])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.post(name: .dismissShelfPanel, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
shelf_store_clear_all(storePtr)
|
||||||
|
items.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let dismissShelfPanel = Notification.Name("dismissShelfPanel")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class PasteboardMonitor {
|
||||||
|
private var timer: Timer?
|
||||||
|
private var lastChangeCount: Int
|
||||||
|
private var lastCopyTime: Date = .distantPast
|
||||||
|
private let minInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
var onNewClip: ((ClipItem) -> Void)?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.lastChangeCount = NSPasteboard.general.changeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
self?.check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncChangeCount() {
|
||||||
|
lastChangeCount = NSPasteboard.general.changeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private func check() {
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
guard pb.changeCount != lastChangeCount else { return }
|
||||||
|
lastChangeCount = pb.changeCount
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastCopyTime) >= minInterval else { return }
|
||||||
|
lastCopyTime = now
|
||||||
|
|
||||||
|
if pb.types?.contains(NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")) == true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceApp = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
|
||||||
|
|
||||||
|
if let imageData = pb.data(forType: .tiff) ?? pb.data(forType: .png) {
|
||||||
|
let item = ClipItem(
|
||||||
|
id: UUID(), timestamp: now, contentType: .image,
|
||||||
|
textContent: nil, imagePath: nil,
|
||||||
|
sourceApp: sourceApp, isPinned: false,
|
||||||
|
rawImageData: imageData
|
||||||
|
)
|
||||||
|
onNewClip?(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let text = pb.string(forType: .string) {
|
||||||
|
if text.trimmingCharacters(in: .whitespacesAndNewlines).count < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentType: ClipContentType
|
||||||
|
if let _ = URL(string: text), text.hasPrefix("http") {
|
||||||
|
contentType = .url
|
||||||
|
} else {
|
||||||
|
contentType = .text
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = ClipItem(
|
||||||
|
id: UUID(), timestamp: now, contentType: contentType,
|
||||||
|
textContent: text, imagePath: nil,
|
||||||
|
sourceApp: sourceApp, isPinned: false
|
||||||
|
)
|
||||||
|
onNewClip?(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import Cocoa
|
||||||
|
import Quartz
|
||||||
|
|
||||||
|
class ShelfPanel: NSPanel {
|
||||||
|
init(contentRect: NSRect) {
|
||||||
|
super.init(
|
||||||
|
contentRect: contentRect,
|
||||||
|
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
titlebarAppearsTransparent = true
|
||||||
|
titleVisibility = .hidden
|
||||||
|
isMovableByWindowBackground = false
|
||||||
|
level = .floating
|
||||||
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
isOpaque = false
|
||||||
|
backgroundColor = .clear
|
||||||
|
hasShadow = true
|
||||||
|
hidesOnDeactivate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { false }
|
||||||
|
|
||||||
|
override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { true }
|
||||||
|
|
||||||
|
override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) {
|
||||||
|
panel.dataSource = ShelfPreviewController.shared
|
||||||
|
panel.delegate = ShelfPreviewController.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
override func endPreviewPanelControl(_ panel: QLPreviewPanel!) {
|
||||||
|
panel.dataSource = nil
|
||||||
|
panel.delegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func cancelOperation(_ sender: Any?) {
|
||||||
|
animator().alphaValue = 0
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
||||||
|
self?.orderOut(nil)
|
||||||
|
self?.alphaValue = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAtBottom() {
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
let visibleFrame = screen.visibleFrame
|
||||||
|
let panelHeight: CGFloat = 340
|
||||||
|
let panelWidth = visibleFrame.width - 32
|
||||||
|
let x = visibleFrame.minX + 16
|
||||||
|
let y = visibleFrame.minY + 8
|
||||||
|
|
||||||
|
setFrame(NSRect(x: x, y: y, width: panelWidth, height: panelHeight), display: true)
|
||||||
|
|
||||||
|
alphaValue = 0
|
||||||
|
orderFrontRegardless()
|
||||||
|
makeKeyAndOrderFront(nil)
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.12
|
||||||
|
animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,603 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Quartz
|
||||||
|
|
||||||
|
// MARK: - Native Quick Look Controller
|
||||||
|
|
||||||
|
class ShelfPreviewController: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||||
|
static let shared = ShelfPreviewController()
|
||||||
|
|
||||||
|
var currentItem: ClipItem?
|
||||||
|
private var tempURL: URL?
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
QLPreviewPanel.sharedPreviewPanelExists() && (QLPreviewPanel.shared()?.isVisible ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle(item: ClipItem) {
|
||||||
|
if isVisible && currentItem?.id == item.id {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
show(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(_ item: ClipItem) {
|
||||||
|
cleanup()
|
||||||
|
currentItem = item
|
||||||
|
prepareFile()
|
||||||
|
|
||||||
|
guard let panel = QLPreviewPanel.shared() else { return }
|
||||||
|
panel.reloadData()
|
||||||
|
if panel.isVisible {
|
||||||
|
panel.reloadData()
|
||||||
|
} else {
|
||||||
|
panel.orderFront(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss() {
|
||||||
|
if isVisible {
|
||||||
|
QLPreviewPanel.shared()?.orderOut(nil)
|
||||||
|
}
|
||||||
|
currentItem = nil
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareFile() {
|
||||||
|
guard let item = currentItem else { return }
|
||||||
|
|
||||||
|
switch item.contentType {
|
||||||
|
case .image:
|
||||||
|
break
|
||||||
|
case .text:
|
||||||
|
if let text = item.textContent {
|
||||||
|
let url = tempDir().appendingPathComponent("clipboard.txt")
|
||||||
|
try? text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
tempURL = url
|
||||||
|
}
|
||||||
|
case .url:
|
||||||
|
if let urlString = item.textContent {
|
||||||
|
let url = tempDir().appendingPathComponent("link.webloc")
|
||||||
|
let plist: [String: String] = ["URL": urlString]
|
||||||
|
if let data = try? PropertyListSerialization.data(
|
||||||
|
fromPropertyList: plist, format: .xml, options: 0
|
||||||
|
) {
|
||||||
|
try? data.write(to: url)
|
||||||
|
}
|
||||||
|
tempURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tempDir() -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("shelf-preview")
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanup() {
|
||||||
|
if let url = tempURL {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
tempURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - QLPreviewPanelDataSource
|
||||||
|
|
||||||
|
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
|
||||||
|
currentItem != nil ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
|
||||||
|
guard let item = currentItem else { return nil }
|
||||||
|
|
||||||
|
switch item.contentType {
|
||||||
|
case .image:
|
||||||
|
if let path = item.imagePath {
|
||||||
|
return NSURL(fileURLWithPath: path)
|
||||||
|
}
|
||||||
|
case .text, .url:
|
||||||
|
if let url = tempURL {
|
||||||
|
return url as NSURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shelf View
|
||||||
|
|
||||||
|
struct ShelfView: View {
|
||||||
|
@ObservedObject var store: ClipStore
|
||||||
|
@State private var selectedID: UUID? = nil
|
||||||
|
@State private var expandedItem: UUID? = nil
|
||||||
|
@State private var expandedSelection: Int? = nil
|
||||||
|
|
||||||
|
private var sortedItems: [ClipItem] {
|
||||||
|
store.items.sorted { a, b in
|
||||||
|
if a.isPinned != b.isPinned { return a.isPinned }
|
||||||
|
return a.timestamp > b.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
shelf
|
||||||
|
.background(
|
||||||
|
KeyCaptureView(
|
||||||
|
onSpace: handleSpace,
|
||||||
|
onEscape: handleEscape,
|
||||||
|
onArrow: handleArrow,
|
||||||
|
onDelete: handleDelete,
|
||||||
|
onReturn: handleReturn,
|
||||||
|
onCopy: handleReturn
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shelf: some View {
|
||||||
|
let items = sortedItems
|
||||||
|
let trayHeight: CGFloat = 260
|
||||||
|
return ZStack(alignment: .bottom) {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.black.opacity(0.7))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(Color.white.opacity(0.5), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(height: trayHeight)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
cardGroup(for: item, in: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedID) {
|
||||||
|
if let id = selectedID {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
|
proxy.scrollTo(id, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardGroup(for item: ClipItem, in items: [ClipItem]) -> some View {
|
||||||
|
let isExpanded = expandedItem == item.id
|
||||||
|
let hasNeighbors = item.displacedPrev != nil || item.displacedNext != nil
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if isExpanded, let prevIdx = item.displacedPrev,
|
||||||
|
prevIdx >= 0, prevIdx < items.count {
|
||||||
|
NeighborPeekCard(
|
||||||
|
item: items[prevIdx],
|
||||||
|
isHighlighted: expandedSelection == 0
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.onTapGesture {
|
||||||
|
expandedSelection = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClipCardView(
|
||||||
|
item: item,
|
||||||
|
isSelected: selectedID == item.id,
|
||||||
|
showNeighborHint: selectedID == item.id && hasNeighbors && !isExpanded
|
||||||
|
)
|
||||||
|
.id(item.id)
|
||||||
|
.onTapGesture {
|
||||||
|
if selectedID == item.id {
|
||||||
|
if hasNeighbors {
|
||||||
|
if isExpanded {
|
||||||
|
collapseExpansion()
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
|
||||||
|
expandedItem = item.id
|
||||||
|
expandedSelection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collapseExpansion()
|
||||||
|
selectedID = item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExpanded, let nextIdx = item.displacedNext,
|
||||||
|
nextIdx >= 0, nextIdx < items.count {
|
||||||
|
NeighborPeekCard(
|
||||||
|
item: items[nextIdx],
|
||||||
|
isHighlighted: expandedSelection == 1
|
||||||
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.onTapGesture {
|
||||||
|
expandedSelection = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collapseExpansion() {
|
||||||
|
guard expandedItem != nil else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
|
expandedItem = nil
|
||||||
|
expandedSelection = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSpace() {
|
||||||
|
guard let id = selectedID,
|
||||||
|
let item = sortedItems.first(where: { $0.id == id }) else {
|
||||||
|
ShelfPreviewController.shared.dismiss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ShelfPreviewController.shared.toggle(item: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEscape() {
|
||||||
|
if ShelfPreviewController.shared.isVisible {
|
||||||
|
ShelfPreviewController.shared.dismiss()
|
||||||
|
} else if expandedItem != nil {
|
||||||
|
collapseExpansion()
|
||||||
|
} else {
|
||||||
|
NotificationCenter.default.post(name: .dismissShelfPanel, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleArrow(_ direction: ArrowDirection) {
|
||||||
|
if let expandedID = expandedItem,
|
||||||
|
let item = sortedItems.first(where: { $0.id == expandedID }) {
|
||||||
|
let items = sortedItems
|
||||||
|
switch (direction, expandedSelection) {
|
||||||
|
case (.left, nil):
|
||||||
|
if let p = item.displacedPrev, p >= 0, p < items.count {
|
||||||
|
expandedSelection = 0
|
||||||
|
}
|
||||||
|
case (.right, nil):
|
||||||
|
if let n = item.displacedNext, n >= 0, n < items.count {
|
||||||
|
expandedSelection = 1
|
||||||
|
}
|
||||||
|
case (.right, .some(0)):
|
||||||
|
expandedSelection = nil
|
||||||
|
case (.left, .some(1)):
|
||||||
|
expandedSelection = nil
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let items = sortedItems
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
|
||||||
|
if ShelfPreviewController.shared.isVisible {
|
||||||
|
ShelfPreviewController.shared.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let currentID = selectedID,
|
||||||
|
let idx = items.firstIndex(where: { $0.id == currentID }) else {
|
||||||
|
selectedID = items.first?.id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch direction {
|
||||||
|
case .left:
|
||||||
|
if idx > 0 { selectedID = items[idx - 1].id }
|
||||||
|
case .right:
|
||||||
|
if idx < items.count - 1 { selectedID = items[idx + 1].id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDelete() {
|
||||||
|
guard let id = selectedID,
|
||||||
|
let item = sortedItems.first(where: { $0.id == id }) else { return }
|
||||||
|
let items = sortedItems
|
||||||
|
let idx = items.firstIndex(where: { $0.id == id })
|
||||||
|
if ShelfPreviewController.shared.currentItem?.id == id {
|
||||||
|
ShelfPreviewController.shared.dismiss()
|
||||||
|
}
|
||||||
|
collapseExpansion()
|
||||||
|
store.delete(item)
|
||||||
|
|
||||||
|
if let idx = idx {
|
||||||
|
let remaining = sortedItems
|
||||||
|
if !remaining.isEmpty {
|
||||||
|
let newIdx = min(idx, remaining.count - 1)
|
||||||
|
selectedID = remaining[newIdx].id
|
||||||
|
} else {
|
||||||
|
selectedID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReturn() {
|
||||||
|
if let expandedID = expandedItem,
|
||||||
|
let item = sortedItems.first(where: { $0.id == expandedID }),
|
||||||
|
let sel = expandedSelection {
|
||||||
|
let items = sortedItems
|
||||||
|
let idx = sel == 0 ? item.displacedPrev : item.displacedNext
|
||||||
|
if let idx = idx, idx >= 0, idx < items.count {
|
||||||
|
store.copyToClipboard(items[idx])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let id = selectedID,
|
||||||
|
let item = sortedItems.first(where: { $0.id == id }) else { return }
|
||||||
|
store.copyToClipboard(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ArrowDirection {
|
||||||
|
case left, right
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clip Card
|
||||||
|
|
||||||
|
struct ClipCardView: View {
|
||||||
|
let item: ClipItem
|
||||||
|
let isSelected: Bool
|
||||||
|
var showNeighborHint: Bool = false
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
titleBar
|
||||||
|
.frame(width: 180)
|
||||||
|
|
||||||
|
cardContent
|
||||||
|
.frame(width: 180, height: 230)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if item.isPinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Text(item.relativeTime)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
Spacer()
|
||||||
|
if showNeighborHint {
|
||||||
|
Image(systemName: "arrow.left.arrow.right")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
typeIcon
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.black.opacity(0.86))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.strokeBorder(isSelected
|
||||||
|
? Color.accentColor
|
||||||
|
: Color.white.opacity(0.4), lineWidth: isSelected ? 2 : 1)
|
||||||
|
)
|
||||||
|
.onHover { isHovered = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var titleBar: some View {
|
||||||
|
let path = item.titlePath
|
||||||
|
if !path.isEmpty {
|
||||||
|
Text(titleAttributed(path))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
.shadow(color: .white.opacity(0.6), radius: 3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.white.opacity(0.08))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titleAttributed(_ path: String) -> AttributedString {
|
||||||
|
let components = path.components(separatedBy: "/")
|
||||||
|
if components.count > 1, let filename = components.last, !filename.isEmpty {
|
||||||
|
let dirPart = String(path.dropLast(filename.count))
|
||||||
|
var dir = AttributedString(dirPart)
|
||||||
|
dir.font = .system(size: 13, design: .monospaced)
|
||||||
|
dir.foregroundColor = .white.opacity(0.7)
|
||||||
|
|
||||||
|
var file = AttributedString(filename)
|
||||||
|
file.font = .system(size: 17, weight: .bold, design: .monospaced)
|
||||||
|
file.foregroundColor = .white
|
||||||
|
return dir + file
|
||||||
|
}
|
||||||
|
var attr = AttributedString(path)
|
||||||
|
attr.font = .system(size: 17, weight: .bold, design: .monospaced)
|
||||||
|
attr.foregroundColor = .white
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var cardContent: some View {
|
||||||
|
switch item.contentType {
|
||||||
|
case .image:
|
||||||
|
if let image = item.loadImage() {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 180, height: 230)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
placeholder("photo")
|
||||||
|
}
|
||||||
|
case .url:
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Image(systemName: "link")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(item.preview)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.lineLimit(7)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
case .text:
|
||||||
|
Text(item.preview)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.lineLimit(9)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func placeholder(_ symbol: String) -> some View {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 30))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeIcon: some View {
|
||||||
|
Group {
|
||||||
|
switch item.contentType {
|
||||||
|
case .text: Image(systemName: "doc.text")
|
||||||
|
case .url: Image(systemName: "link")
|
||||||
|
case .image: Image(systemName: "photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Neighbor Peek Card
|
||||||
|
|
||||||
|
struct NeighborPeekCard: View {
|
||||||
|
let item: ClipItem
|
||||||
|
let isHighlighted: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
peekContent
|
||||||
|
.frame(width: 120, height: 112)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
|
||||||
|
Text(item.relativeTime)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.black.opacity(0.86))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(
|
||||||
|
isHighlighted
|
||||||
|
? Color.accentColor
|
||||||
|
: Color.white.opacity(0.4),
|
||||||
|
lineWidth: isHighlighted ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var peekContent: some View {
|
||||||
|
switch item.contentType {
|
||||||
|
case .image:
|
||||||
|
if let image = item.loadImage() {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 120, height: 112)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
case .url, .text:
|
||||||
|
Text(item.preview)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.lineLimit(7)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Capture
|
||||||
|
|
||||||
|
struct KeyCaptureView: NSViewRepresentable {
|
||||||
|
let onSpace: () -> Void
|
||||||
|
let onEscape: () -> Void
|
||||||
|
let onArrow: (ArrowDirection) -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let onReturn: () -> Void
|
||||||
|
let onCopy: () -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> KeyCaptureNSView {
|
||||||
|
let view = KeyCaptureNSView()
|
||||||
|
view.onSpace = onSpace
|
||||||
|
view.onEscape = onEscape
|
||||||
|
view.onArrow = onArrow
|
||||||
|
view.onDelete = onDelete
|
||||||
|
view.onReturn = onReturn
|
||||||
|
view.onCopy = onCopy
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: KeyCaptureNSView, context: Context) {
|
||||||
|
nsView.onSpace = onSpace
|
||||||
|
nsView.onEscape = onEscape
|
||||||
|
nsView.onArrow = onArrow
|
||||||
|
nsView.onDelete = onDelete
|
||||||
|
nsView.onReturn = onReturn
|
||||||
|
nsView.onCopy = onCopy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class KeyCaptureNSView: NSView {
|
||||||
|
var onSpace: (() -> Void)?
|
||||||
|
var onEscape: (() -> Void)?
|
||||||
|
var onArrow: ((ArrowDirection) -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
var onReturn: (() -> Void)?
|
||||||
|
var onCopy: (() -> Void)?
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.window?.makeFirstResponder(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "c" {
|
||||||
|
onCopy?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch Int(event.keyCode) {
|
||||||
|
case 49: onSpace?()
|
||||||
|
case 53: onEscape?()
|
||||||
|
case 123: onArrow?(.left)
|
||||||
|
case 124: onArrow?(.right)
|
||||||
|
case 51: onDelete?()
|
||||||
|
case 36: onReturn?()
|
||||||
|
default: super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
Loading…
Reference in New Issue