Init' it!

This commit is contained in:
pszsh 2026-03-13 12:06:48 -07:00
commit 83be80f620
21 changed files with 2558 additions and 0 deletions

12
LICENCE Normal file
View File

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

44
bridge/shelf_core.h Normal file
View File

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

40
build.sh Executable file
View File

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

680
core/Cargo.lock generated Normal file
View File

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

22
core/Cargo.toml Normal file
View File

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

65
core/src/clip.rs Normal file
View File

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

106
core/src/icon.rs Normal file
View File

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

187
core/src/lib.rs Normal file
View File

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

14
core/src/main.rs Normal file
View File

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

221
core/src/store.rs Normal file
View File

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

44
debug.sh Executable file
View File

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

11
install.sh Executable file
View File

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

26
resources/Info.plist Normal file
View File

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

1
resources/shelf.svg Normal file
View File

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

143
src/AppDelegate.swift Normal file
View File

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

71
src/ClipItem.swift Normal file
View File

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

121
src/ClipStore.swift Normal file
View File

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

View File

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

65
src/ShelfPanel.swift Normal file
View File

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

603
src/ShelfView.swift Normal file
View File

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

6
src/main.swift Normal file
View File

@ -0,0 +1,6 @@
import Cocoa
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()