commit 83be80f6207d19004acd43df5a2152cbb2c85fe8 Author: pszsh Date: Fri Mar 13 12:06:48 2026 -0700 Init' it! diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..1cebcb5 --- /dev/null +++ b/LICENCE @@ -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. diff --git a/bridge/shelf_core.h b/bridge/shelf_core.h new file mode 100644 index 0000000..d162fcf --- /dev/null +++ b/bridge/shelf_core.h @@ -0,0 +1,44 @@ +#ifndef SHELF_CORE_H +#define SHELF_CORE_H + +#include +#include +#include + +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 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8e9ec9a --- /dev/null +++ b/build.sh @@ -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" diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 0000000..e3c88d8 --- /dev/null +++ b/core/Cargo.lock @@ -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", +] diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..e070849 --- /dev/null +++ b/core/Cargo.toml @@ -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" diff --git a/core/src/clip.rs b/core/src/clip.rs new file mode 100644 index 0000000..b1f1a26 --- /dev/null +++ b/core/src/clip.rs @@ -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, + pub image_path: Option, + pub source_app: Option, + pub is_pinned: bool, + pub displaced_prev: Option, + pub displaced_next: Option, +} + +#[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 { + 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)?, + }) + } +} diff --git a/core/src/icon.rs b/core/src/icon.rs new file mode 100644 index 0000000..37160e9 --- /dev/null +++ b/core/src/icon.rs @@ -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 = 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]); + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..2fca765 --- /dev/null +++ b/core/src/lib.rs @@ -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) -> *mut c_char { + match s { + Some(s) => to_c_string(s), + None => ptr::null_mut(), + } +} + +unsafe fn from_c(p: *const c_char) -> Option { + 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 = 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) +} diff --git a/core/src/main.rs b/core/src/main.rs new file mode 100644 index 0000000..c216a2a --- /dev/null +++ b/core/src/main.rs @@ -0,0 +1,14 @@ +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: shelf-icon [--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); + } +} diff --git a/core/src/store.rs b/core/src/store.rs new file mode 100644 index 0000000..5899b08 --- /dev/null +++ b/core/src/store.rs @@ -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 { + 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 { + if let Some(existing_id) = self.find_duplicate(clip) { + let ordered: Vec = 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 = Some(pos as i64); + let next: Option = + 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>(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 { + 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)> = 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(); + } + } +} diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..871c3e7 --- /dev/null +++ b/debug.sh @@ -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" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..69d65ac --- /dev/null +++ b/install.sh @@ -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" diff --git a/resources/Info.plist b/resources/Info.plist new file mode 100644 index 0000000..48eba72 --- /dev/null +++ b/resources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleIdentifier + org.elseif.shelf + CFBundleName + Shelf + CFBundleDisplayName + Shelf + CFBundleVersion + 1 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + Shelf + LSMinimumSystemVersion + 14.0 + CFBundleIconFile + AppIcon + LSUIElement + + + diff --git a/resources/shelf.svg b/resources/shelf.svg new file mode 100644 index 0000000..450592a --- /dev/null +++ b/resources/shelf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift new file mode 100644 index 0000000..d6310d9 --- /dev/null +++ b/src/AppDelegate.swift @@ -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.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 + ) + } +} diff --git a/src/ClipItem.swift b/src/ClipItem.swift new file mode 100644 index 0000000..4c3f7d9 --- /dev/null +++ b/src/ClipItem.swift @@ -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) + } +} diff --git a/src/ClipStore.swift b/src/ClipStore.swift new file mode 100644 index 0000000..afb7a8f --- /dev/null +++ b/src/ClipStore.swift @@ -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..= 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") +} diff --git a/src/PasteboardMonitor.swift b/src/PasteboardMonitor.swift new file mode 100644 index 0000000..205366b --- /dev/null +++ b/src/PasteboardMonitor.swift @@ -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) + } + } +} diff --git a/src/ShelfPanel.swift b/src/ShelfPanel.swift new file mode 100644 index 0000000..885b5e0 --- /dev/null +++ b/src/ShelfPanel.swift @@ -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 + } + } +} diff --git a/src/ShelfView.swift b/src/ShelfView.swift new file mode 100644 index 0000000..5f30db6 --- /dev/null +++ b/src/ShelfView.swift @@ -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) + } + } +} diff --git a/src/main.swift b/src/main.swift new file mode 100644 index 0000000..eb33820 --- /dev/null +++ b/src/main.swift @@ -0,0 +1,6 @@ +import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run()