feat: add lazy loading, non-blocking bookmarks, and file search fix

- Add lazy loading for result lists (load more on scroll/selection)
- Add non-blocking bookmark loading with JSON cache
- Add Firefox favicon extraction and caching
- Fix dynamic provider filtering (files/calc/websearch in All mode)
- Fix clippy warnings across core and plugins
- Add apex-neon theme
- Add aur/ to gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 06:11:50 +01:00
parent 43f7228be2
commit abd4df6939
21 changed files with 1931 additions and 220 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ aur/*/*.tar.xz
aur/*/*.pkg.tar.*
# Keep PKGBUILD and .SRCINFO tracked
.SRCINFO
aur/

675
Cargo.lock generated
View File

@@ -77,6 +77,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -298,6 +304,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -316,11 +331,20 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
dependencies = [
"serde_core",
]
[[package]]
name = "block"
@@ -328,6 +352,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -367,6 +400,12 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -524,6 +563,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const_panic"
version = "0.2.15"
@@ -564,6 +609,30 @@ version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -588,12 +657,42 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -603,6 +702,18 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]]
name = "dirs"
version = "5.0.1"
@@ -645,11 +756,20 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
@@ -743,6 +863,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -796,12 +927,29 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -868,6 +1016,17 @@ dependencies = [
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.31"
@@ -1018,6 +1177,16 @@ dependencies = [
"windows-result 0.4.1",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -1417,12 +1586,32 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1441,6 +1630,33 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -1694,7 +1910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
]
[[package]]
@@ -1770,6 +1986,9 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "libc"
@@ -1797,6 +2016,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.12"
@@ -1805,6 +2030,18 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"libc",
"redox_syscall 0.7.0",
]
[[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]]
@@ -1915,6 +2152,16 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2122,6 +2369,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -2175,6 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -2394,6 +2658,8 @@ dependencies = [
"owlry-plugin-api",
"serde",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
@@ -2572,7 +2838,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -2583,6 +2849,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2632,6 +2907,27 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2747,7 +3043,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -2788,14 +3084,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
@@ -2805,7 +3122,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.16",
]
[[package]]
@@ -2826,6 +3152,15 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -2935,6 +3270,26 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rune"
version = "0.14.1"
@@ -3262,6 +3617,28 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -3287,6 +3664,16 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -3310,6 +3697,9 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]]
name = "socket2"
@@ -3321,6 +3711,213 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"serde_json",
"sha2",
"smallvec",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tracing",
"url",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.111",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.111",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"bytes",
"crc",
"digest",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.17",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.17",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
"futures-intrusive",
"futures-util",
"libsqlite3-sys",
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.17",
"tracing",
"url",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3333,6 +3930,17 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3601,6 +4209,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.17"
@@ -3762,6 +4381,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -3856,6 +4476,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "typewit"
version = "1.14.2"
@@ -3879,12 +4505,33 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -3980,6 +4627,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
@@ -4079,6 +4732,16 @@ dependencies = [
"winsafe",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]]
name = "winapi"
version = "0.3.9"

View File

@@ -25,3 +25,7 @@ dirs = "5.0"
# For parsing Chrome bookmarks JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# For reading Firefox bookmarks (places.sqlite)
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["rt-multi-thread"] }

View File

@@ -1,9 +1,9 @@
//! Bookmarks Plugin for Owlry
//!
//! A static provider that reads browser bookmarks from Chrome/Chromium.
//! Firefox support would require the rusqlite crate for reading places.sqlite.
//! A static provider that reads browser bookmarks from various browsers.
//!
//! Supported browsers:
//! - Firefox (via places.sqlite using SQLx)
//! - Chrome
//! - Chromium
//! - Brave
@@ -14,8 +14,14 @@ use owlry_plugin_api::{
owlry_plugin, PluginInfo, PluginItem, ProviderHandle, ProviderInfo, ProviderKind, API_VERSION,
};
use serde::Deserialize;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::Row;
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
// Plugin metadata
const PLUGIN_ID: &str = "bookmarks";
@@ -27,17 +33,141 @@ const PLUGIN_DESCRIPTION: &str = "Browser bookmark search";
const PROVIDER_ID: &str = "bookmarks";
const PROVIDER_NAME: &str = "Bookmarks";
const PROVIDER_PREFIX: &str = ":bm";
const PROVIDER_ICON: &str = "web-browser";
const PROVIDER_ICON: &str = "user-bookmarks-symbolic";
const PROVIDER_TYPE_ID: &str = "bookmarks";
/// Bookmarks provider state - holds cached items
struct BookmarksState {
/// Cached bookmark items (returned immediately on refresh)
items: Vec<PluginItem>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self { items: Vec::new() }
Self {
items: Vec::new(),
loading: Arc::new(AtomicBool::new(false)),
}
}
/// Get or create the favicon cache directory
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
/// Ensure the favicon cache directory exists
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL to create a cache filename
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
/// Get the bookmark cache file path
fn bookmark_cache_file() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/bookmarks.json"))
}
/// Load cached bookmarks from disk (fast)
fn load_cached_bookmarks() -> Vec<PluginItem> {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return Vec::new(),
};
if !cache_file.exists() {
return Vec::new();
}
let content = match fs::read_to_string(&cache_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// Parse cached bookmarks (simple JSON format)
#[derive(serde::Deserialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
cached
.into_iter()
.map(|b| {
let mut item = PluginItem::new(b.id, b.name, b.command)
.with_icon(&b.icon)
.with_keywords(vec!["bookmark".to_string()]);
if let Some(desc) = b.description {
item = item.with_description(desc);
}
item
})
.collect()
}
/// Save bookmarks to cache file
fn save_cached_bookmarks(items: &[PluginItem]) {
let cache_file = match Self::bookmark_cache_file() {
Some(f) => f,
None => return,
};
// Ensure cache directory exists
if let Some(parent) = cache_file.parent() {
let _ = fs::create_dir_all(parent);
}
#[derive(serde::Serialize)]
struct CachedBookmark {
id: String,
name: String,
command: String,
description: Option<String>,
icon: String,
}
let cached: Vec<CachedBookmark> = items
.iter()
.map(|item| {
let desc: Option<String> = match &item.description {
abi_stable::std_types::ROption::RSome(s) => Some(s.to_string()),
abi_stable::std_types::ROption::RNone => None,
};
let icon: String = match &item.icon {
abi_stable::std_types::ROption::RSome(s) => s.to_string(),
abi_stable::std_types::ROption::RNone => PROVIDER_ICON.to_string(),
};
CachedBookmark {
id: item.id.to_string(),
name: item.name.to_string(),
command: item.command.to_string(),
description: desc,
icon,
}
})
.collect();
if let Ok(json) = serde_json::to_string(&cached) {
let _ = fs::write(&cache_file, json);
}
}
fn chromium_bookmark_paths() -> Vec<PathBuf> {
@@ -61,18 +191,87 @@ impl BookmarksState {
paths
}
fn load_bookmarks(&mut self) {
self.items.clear();
fn firefox_places_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Load Chrome/Chromium bookmarks
for path in Self::chromium_bookmark_paths() {
if path.exists() {
self.read_chrome_bookmarks(&path);
if let Some(home) = dirs::home_dir() {
let firefox_dir = home.join(".mozilla/firefox");
if firefox_dir.exists() {
// Find all profile directories
if let Ok(entries) = fs::read_dir(&firefox_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
}
}
paths
}
/// Find Firefox favicons.sqlite paths (paired with places.sqlite)
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
}
fn read_chrome_bookmarks(&mut self, path: &PathBuf) {
fn load_bookmarks(&mut self) {
// Fast path: load from cache immediately
if self.items.is_empty() {
self.items = Self::load_cached_bookmarks();
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
thread::spawn(move || {
let mut items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut items);
}
}
// Load Firefox bookmarks with favicons (async via tokio)
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => {
loading.store(false, Ordering::SeqCst);
return;
}
};
for path in Self::firefox_places_paths() {
rt.block_on(async {
Self::read_firefox_bookmarks_async(&path, &mut items).await;
});
}
// Save to cache for next startup
Self::save_cached_bookmarks(&items);
loading.store(false, Ordering::SeqCst);
});
}
/// Read Chrome bookmarks (static helper for background thread)
fn read_chrome_bookmarks_static(path: &PathBuf, items: &mut Vec<PluginItem>) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
@@ -83,29 +282,27 @@ impl BookmarksState {
Err(_) => return,
};
// Process bookmark bar and other folders
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
self.process_chrome_folder(&bar);
Self::process_chrome_folder_static(&bar, items);
}
if let Some(other) = roots.other {
self.process_chrome_folder(&other);
Self::process_chrome_folder_static(&other, items);
}
if let Some(synced) = roots.synced {
self.process_chrome_folder(&synced);
Self::process_chrome_folder_static(&synced, items);
}
}
}
fn process_chrome_folder(&mut self, folder: &ChromeBookmarkNode) {
fn process_chrome_folder_static(folder: &ChromeBookmarkNode, items: &mut Vec<PluginItem>) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
self.items.push(
items.push(
PluginItem::new(
format!("bookmark:{}", url),
name,
@@ -113,19 +310,199 @@ impl BookmarksState {
)
.with_description(url.clone())
.with_icon(PROVIDER_ICON)
.with_keywords(vec!["bookmark".to_string(), "web".to_string()]),
.with_keywords(vec!["bookmark".to_string(), "chrome".to_string()]),
);
}
}
Some("folder") => {
// Recursively process subfolders
self.process_chrome_folder(child);
Self::process_chrome_folder_static(child, items);
}
_ => {}
}
}
}
}
/// Read Firefox bookmarks asynchronously
async fn read_firefox_bookmarks_async(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let temp_db = temp_dir.join("owlry_places_temp.sqlite");
if fs::copy(places_path, &temp_db).is_err() {
return;
}
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join("owlry_favicons_temp.sqlite");
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let db_url = format!("sqlite:{}?mode=ro", temp_db.display());
let favicons_url = if favicons_path.is_some() {
Some(format!("sqlite:{}?mode=ro", temp_favicons.display()))
} else {
None
};
let cache_dir = Self::ensure_favicon_cache_dir();
let bookmarks = Self::fetch_firefox_bookmarks_with_favicons(
&db_url,
favicons_url.as_deref(),
cache_dir.as_ref(),
)
.await;
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| PROVIDER_ICON.to_string());
items.push(
PluginItem::new(
format!("bookmark:firefox:{}", url),
title,
format!("xdg-open '{}'", url.replace('\'', "'\\''")),
)
.with_description(url)
.with_icon(&icon)
.with_keywords(vec!["bookmark".to_string(), "firefox".to_string()]),
);
}
}
/// Fetch Firefox bookmarks with their favicons
async fn fetch_firefox_bookmarks_with_favicons(
places_url: &str,
favicons_url: Option<&str>,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
// First, fetch bookmarks from places.sqlite
let pool = match SqlitePoolOptions::new()
.max_connections(1)
.connect(places_url)
.await
{
Ok(p) => p,
Err(_) => return Vec::new(),
};
// Query bookmarks joining moz_bookmarks with moz_places
// type=1 means URL bookmarks (not folders, separators, etc.)
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let rows = match sqlx::query(query).fetch_all(&pool).await {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = rows
.into_iter()
.filter_map(|row| {
let title: Option<String> = row.get("title");
let url: Option<String> = row.get("url");
match (title, url) {
(Some(t), Some(u)) => Some((t, u)),
_ => None,
}
})
.collect();
// If no favicons database or cache dir, return without favicons
let (favicons_url, cache_dir) = match (favicons_url, cache_dir) {
(Some(f), Some(c)) => (f, c),
_ => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Connect to favicons database
let fav_pool = match SqlitePoolOptions::new()
.max_connections(1)
.connect(favicons_url)
.await
{
Ok(p) => p,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
// Fetch favicons for each URL
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_pool, &url, cache_dir).await;
results.push((title, url, favicon_path));
}
results
}
/// Get favicon for a URL, caching to file if needed
async fn get_favicon_for_url(
pool: &sqlx::SqlitePool,
page_url: &str,
cache_dir: &Path,
) -> Option<String> {
// Check if already cached
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Query favicon data from database
// Join moz_pages_w_icons -> moz_icons_to_pages -> moz_icons
// Prefer smaller icons (32px) for efficiency
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let row = sqlx::query(query)
.bind(page_url)
.fetch_optional(pool)
.await
.ok()??;
let data: Vec<u8> = row.get("data");
if data.is_empty() {
return None;
}
// Write favicon data to cache file
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
}
// Chrome bookmark JSON structures
@@ -241,6 +618,14 @@ mod tests {
assert!(!paths.is_empty());
}
#[test]
fn test_firefox_paths() {
// This will find paths if Firefox is installed
let paths = BookmarksState::firefox_places_paths();
// Path detection should work (may be empty if Firefox not installed)
assert!(paths.len() >= 0);
}
#[test]
fn test_parse_chrome_bookmarks() {
let json = r#"{

View File

@@ -423,6 +423,7 @@ impl EmojiState {
name.to_string(),
format!("printf '%s' '{}' | wl-copy", emoji),
)
.with_icon(*emoji) // Use emoji character as icon
.with_description(format!("{} {}", emoji, keywords))
.with_keywords(vec![name.to_string(), keywords.to_string()]),
);

View File

@@ -57,31 +57,31 @@ impl PomodoroConfig {
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table()) {
return Self::from_toml_table(pomodoro);
}
}
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.pomodoro] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(pomodoro) = plugins.get("pomodoro").and_then(|v| v.as_table())
{
return Self::from_toml_table(pomodoro);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let work_mins = providers
.get("pomodoro_work_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_WORK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
let break_mins = providers
.get("pomodoro_break_mins")
.and_then(|v| v.as_integer())
.map(|v| v as u32)
.unwrap_or(DEFAULT_BREAK_MINS);
return Self { work_mins, break_mins };
}
return Self { work_mins, break_mins };
}
}

View File

@@ -82,41 +82,41 @@ impl WeatherConfig {
let config_content = config_path
.and_then(|p| fs::read_to_string(p).ok());
if let Some(content) = config_content {
if let Ok(toml) = content.parse::<toml::Table>() {
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) {
if let Some(weather) = plugins.get("weather").and_then(|v| v.as_table()) {
return Self::from_toml_table(weather);
}
}
if let Some(content) = config_content
&& let Ok(toml) = content.parse::<toml::Table>()
{
// Try [plugins.weather] first (new format)
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(weather) = plugins.get("weather").and_then(|v| v.as_table())
{
return Self::from_toml_table(weather);
}
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
// Fallback to [providers] section (old format)
if let Some(providers) = toml.get("providers").and_then(|v| v.as_table()) {
let provider_str = providers
.get("weather_provider")
.and_then(|v| v.as_str())
.unwrap_or("wttr.in");
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let provider = provider_str.parse().unwrap_or(WeatherProviderType::WttrIn);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let api_key = providers
.get("weather_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let location = providers
.get("weather_location")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Self {
provider,
api_key,
location,
};
}
return Self {
provider,
api_key,
location,
};
}
}

View File

@@ -56,7 +56,10 @@ impl OwlryApp {
let native_providers = Self::load_native_plugins(&config.borrow());
// Create provider manager with native plugins
#[cfg(feature = "lua")]
let mut provider_manager = ProviderManager::with_native_plugins(native_providers);
#[cfg(not(feature = "lua"))]
let provider_manager = ProviderManager::with_native_plugins(native_providers);
// Load Lua plugins if enabled (requires lua feature)
#[cfg(feature = "lua")]
@@ -75,7 +78,7 @@ impl OwlryApp {
);
let filter = Rc::new(RefCell::new(filter));
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone());
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone());
// Set up layer shell for Wayland overlay behavior
window.init_layer_shell();

View File

@@ -21,6 +21,10 @@ pub struct CliArgs {
#[arg(long, short = 'p', value_delimiter = ',', value_parser = parse_provider)]
pub providers: Option<Vec<ProviderType>>,
/// Custom prompt text for the search input (useful for dmenu mode)
#[arg(long)]
pub prompt: Option<String>,
/// Subcommand to run (if any)
#[command(subcommand)]
pub command: Option<Command>,

View File

@@ -6,10 +6,13 @@ use std::process::Command;
use crate::paths;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub plugins: PluginsConfig,
@@ -17,9 +20,13 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
#[serde(default = "default_true")]
pub show_icons: bool,
#[serde(default = "default_max_results")]
pub max_results: usize,
pub terminal_command: String,
/// Terminal command (auto-detected if not specified)
#[serde(default)]
pub terminal_command: Option<String>,
/// Launch wrapper command for app execution.
/// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --"
/// If None or empty, launches directly via sh -c
@@ -31,6 +38,22 @@ pub struct GeneralConfig {
pub tabs: Vec<String>,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
show_icons: true,
max_results: 100,
terminal_command: None,
launch_wrapper: None,
tabs: default_tabs(),
}
}
}
fn default_max_results() -> usize {
100
}
fn default_tabs() -> Vec<String> {
vec![
"app".to_string(),
@@ -40,9 +63,10 @@ fn default_tabs() -> Vec<String> {
}
/// User-customizable theme colors
/// All fields are optional - unset values inherit from GTK theme
/// All fields are optional - unset values inherit from theme or GTK defaults
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ThemeColors {
// Core colors
pub background: Option<String>,
pub background_secondary: Option<String>,
pub border: Option<String>,
@@ -64,13 +88,21 @@ pub struct ThemeColors {
pub badge_sys: Option<String>,
pub badge_uuctl: Option<String>,
pub badge_web: Option<String>,
// Widget badge colors
pub badge_media: Option<String>,
pub badge_weather: Option<String>,
pub badge_pomo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_width")]
pub width: i32,
#[serde(default = "default_height")]
pub height: i32,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default = "default_border_radius")]
pub border_radius: u32,
/// Theme name: None = GTK default, "owl" = built-in owl theme
#[serde(default)]
@@ -80,10 +112,31 @@ pub struct AppearanceConfig {
pub colors: ThemeColors,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
}
}
}
fn default_width() -> i32 { 850 }
fn default_height() -> i32 { 650 }
fn default_font_size() -> u32 { 14 }
fn default_border_radius() -> u32 { 12 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
#[serde(default = "default_true")]
pub applications: bool,
#[serde(default = "default_true")]
pub commands: bool,
#[serde(default = "default_true")]
pub uuctl: bool,
/// Enable calculator provider (= expression or calc expression)
#[serde(default = "default_true")]
@@ -159,6 +212,36 @@ pub struct ProvidersConfig {
pub pomodoro_break_mins: u32,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
}
}
}
/// Configuration for plugins
///
/// Supports per-plugin configuration via `[plugins.<name>]` sections:
@@ -450,57 +533,7 @@ fn command_exists(cmd: &str) -> bool {
.unwrap_or(false)
}
impl Default for Config {
fn default() -> Self {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
Self {
general: GeneralConfig {
show_icons: true,
max_results: 10,
terminal_command: terminal,
launch_wrapper: detect_launch_wrapper(),
tabs: default_tabs(),
},
appearance: AppearanceConfig {
width: 850,
height: 650,
font_size: 14,
border_radius: 12,
theme: None,
colors: ThemeColors::default(),
},
providers: ProvidersConfig {
applications: true,
commands: true,
uuctl: true,
calculator: true,
frecency: true,
frecency_weight: 0.3,
websearch: true,
search_engine: "duckduckgo".to_string(),
system: true,
ssh: true,
clipboard: true,
bookmarks: true,
emoji: true,
scripts: true,
files: true,
// Widget providers
media: true,
weather: false,
weather_provider: "wttr.in".to_string(),
weather_api_key: None,
weather_location: Some("Berlin".to_string()),
pomodoro: false,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
},
plugins: PluginsConfig::default(),
}
}
}
// Note: Config derives Default via #[derive(Default)] - all sub-structs have impl Default
impl Config {
pub fn config_path() -> Option<PathBuf> {
@@ -517,23 +550,37 @@ impl Config {
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::config_path().ok_or("Could not determine config path")?;
if !path.exists() {
let mut config = if !path.exists() {
info!("Config file not found, using defaults");
return Ok(Self::default());
Self::default()
} else {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
config
};
// Auto-detect terminal if not configured or configured terminal doesn't exist
match &config.general.terminal_command {
None => {
let terminal = detect_terminal();
info!("Detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) if !command_exists(term) => {
warn!("Configured terminal '{}' not found, auto-detecting", term);
let terminal = detect_terminal();
info!("Using detected terminal: {}", terminal);
config.general.terminal_command = Some(terminal);
}
Some(term) => {
debug!("Using configured terminal: {}", term);
}
}
let content = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&content)?;
info!("Loaded config from {:?}", path);
// Validate terminal - if configured terminal doesn't exist, auto-detect
if !command_exists(&config.general.terminal_command) {
warn!(
"Configured terminal '{}' not found, auto-detecting",
config.general.terminal_command
);
config.general.terminal_command = detect_terminal();
info!("Using detected terminal: {}", config.general.terminal_command);
// Auto-detect launch wrapper if not configured
if config.general.launch_wrapper.is_none() {
config.general.launch_wrapper = detect_launch_wrapper();
}
Ok(config)

View File

@@ -64,8 +64,17 @@ impl ProviderFilter {
if config_providers.scripts {
set.insert(ProviderType::Scripts);
}
// Note: Files, Calculator, WebSearch are dynamic providers
// that don't need to be in the filter set - they're triggered by prefix
// Dynamic providers: add to filter set so they work in "All" mode
// but can still be excluded when in single-provider mode
if config_providers.files {
set.insert(ProviderType::Files);
}
if config_providers.calculator {
set.insert(ProviderType::Calculator);
}
if config_providers.websearch {
set.insert(ProviderType::WebSearch);
}
// Default to apps if nothing enabled
if set.is_empty() {
set.insert(ProviderType::Application);
@@ -104,9 +113,11 @@ impl ProviderFilter {
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled OFF {:?}, enabled: {:?}", provider, self.enabled);
} else {
#[cfg(feature = "dev-logging")]
let provider_debug = format!("{:?}", provider);
self.enabled.insert(provider);
#[cfg(feature = "dev-logging")]
debug!("[Filter] Toggled ON {:?}, enabled: {:?}", provider, self.enabled);
debug!("[Filter] Toggled ON {}, enabled: {:?}", provider_debug, self.enabled);
}
}

View File

@@ -156,6 +156,7 @@ pub fn discover_plugins(plugins_dir: &Path) -> PluginResult<HashMap<String, (Plu
}
/// Check if a plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn check_compatibility(manifest: &PluginManifest, owlry_version: &str) -> PluginResult<()> {
if !manifest.is_compatible_with(owlry_version) {
return Err(PluginError::VersionMismatch {
@@ -230,6 +231,7 @@ impl PluginManifest {
}
/// Check if this plugin is compatible with the given owlry version
#[allow(dead_code)]
pub fn is_compatible_with(&self, owlry_version: &str) -> bool {
let req = match semver::VersionReq::parse(&self.plugin.owlry_version) {
Ok(r) => r,

View File

@@ -43,6 +43,7 @@ pub use api::provider::{PluginItem, ProviderRegistration};
#[allow(unused_imports)]
pub use api::{ActionRegistration, HookEvent, ThemeRegistration};
#[allow(unused_imports)]
pub use error::{PluginError, PluginResult};
#[cfg(feature = "lua")]

View File

@@ -80,13 +80,13 @@ impl std::str::FromStr for ProviderType {
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
"emoji" | "emojis" => Ok(ProviderType::Emoji),
"file" | "files" | "find" => Ok(ProviderType::Files),
"file" | "files" | "find" | "filesearch" => Ok(ProviderType::Files),
"media" | "mpris" | "player" => Ok(ProviderType::MediaPlayer),
"pomo" | "pomodoro" | "timer" => Ok(ProviderType::Pomodoro),
"script" | "scripts" => Ok(ProviderType::Scripts),
"ssh" => Ok(ProviderType::Ssh),
"sys" | "system" | "power" => Ok(ProviderType::System),
"uuctl" => Ok(ProviderType::Uuctl),
"uuctl" | "systemd" => Ok(ProviderType::Uuctl),
"weather" => Ok(ProviderType::Weather),
"web" | "websearch" | "search" => Ok(ProviderType::WebSearch),
// Plugin types are prefixed with "plugin:" (e.g., "plugin:github-repos")
@@ -273,12 +273,14 @@ impl ProviderManager {
}
/// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
@@ -394,6 +396,10 @@ impl ProviderManager {
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority scores based on type
for provider in &self.widget_providers {
// Skip if this provider type is filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let base_score = match provider.type_id() {
"weather" => 12000,
"pomodoro" => 11500,
@@ -407,9 +413,15 @@ impl ProviderManager {
}
// Query dynamic providers (calculator, websearch, filesearch)
// Each provider internally checks if the query matches its prefix
// Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() {
for (provider_idx, provider) in self.dynamic_providers.iter().enumerate() {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
let base_score = match provider.type_id() {
"calc" => 10000,

View File

@@ -42,6 +42,16 @@ impl NativeProvider {
}
}
/// Get the ProviderType for this native provider
/// Maps type_id string to the appropriate ProviderType variant
fn get_provider_type(&self) -> ProviderType {
// Parse type_id to get the proper ProviderType
// This uses the FromStr impl which maps strings like "clipboard" -> ProviderType::Clipboard
self.info.type_id.as_str().parse().unwrap_or_else(|_| {
ProviderType::Plugin(self.info.type_id.to_string())
})
}
/// Convert a plugin API item to a core LaunchItem
fn convert_item(&self, item: ApiPluginItem) -> LaunchItem {
LaunchItem {
@@ -49,7 +59,7 @@ impl NativeProvider {
name: item.name.to_string(),
description: item.description.as_ref().map(|s| s.to_string()).into(),
icon: item.icon.as_ref().map(|s| s.to_string()).into(),
provider: ProviderType::Plugin(self.info.type_id.to_string()),
provider: self.get_provider_type(),
command: item.command.to_string(),
terminal: item.terminal,
tags: item.keywords.iter().map(|s| s.to_string()).collect(),
@@ -113,7 +123,7 @@ impl Provider for NativeProvider {
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(self.info.type_id.to_string())
self.get_provider_type()
}
fn refresh(&mut self) {

View File

@@ -67,6 +67,18 @@
opacity: 1;
}
/* Symbolic icons - inherit text color */
.owlry-symbolic-icon {
-gtk-icon-style: symbolic;
}
/* Emoji icon - displayed as large text */
.owlry-emoji-icon {
font-size: 24px;
min-width: 32px;
min-height: 32px;
}
/* Result name */
.owlry-result-name {
font-size: var(--owlry-font-size, 14px);

View File

@@ -72,6 +72,17 @@ pub fn generate_variables_css(config: &AppearanceConfig) -> String {
css.push_str(&format!(" --owlry-badge-web: {};\n", badge_web));
}
// Widget badge colors
if let Some(ref badge_media) = config.colors.badge_media {
css.push_str(&format!(" --owlry-badge-media: {};\n", badge_media));
}
if let Some(ref badge_weather) = config.colors.badge_weather {
css.push_str(&format!(" --owlry-badge-weather: {};\n", badge_weather));
}
if let Some(ref badge_pomo) = config.colors.badge_pomo {
css.push_str(&format!(" --owlry-badge-pomo: {};\n", badge_pomo));
}
css.push_str("}\n");
css
}

View File

@@ -35,6 +35,19 @@ struct SubmenuState {
saved_search: String,
}
/// State for lazy loading results
#[derive(Default)]
struct LazyLoadState {
/// All matching results (may be more than displayed)
all_results: Vec<LaunchItem>,
/// Number of items currently displayed
displayed_count: usize,
}
/// Number of items to display initially and per batch
const INITIAL_RESULTS: usize = 15;
const LOAD_MORE_BATCH: usize = 10;
pub struct MainWindow {
window: ApplicationWindow,
search_entry: Entry,
@@ -51,6 +64,11 @@ pub struct MainWindow {
submenu_state: Rc<RefCell<SubmenuState>>,
/// Parsed tab config (ProviderTypes for cycling)
tab_order: Rc<Vec<ProviderType>>,
/// Custom prompt text (overrides dynamic placeholder when set)
#[allow(dead_code)]
custom_prompt: Option<String>,
/// Lazy loading state
lazy_state: Rc<RefCell<LazyLoadState>>,
}
impl MainWindow {
@@ -60,6 +78,7 @@ impl MainWindow {
providers: Rc<RefCell<ProviderManager>>,
frecency: Rc<RefCell<FrecencyStore>>,
filter: Rc<RefCell<ProviderFilter>>,
custom_prompt: Option<String>,
) -> Self {
let cfg = config.borrow();
@@ -111,24 +130,21 @@ impl MainWindow {
.build();
filter_tabs.add_css_class("owlry-filter-tabs");
// Parse tabs config to ProviderTypes
let tab_order: Vec<ProviderType> = cfg
.general
.tabs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let tab_order = Rc::new(tab_order);
// Get enabled providers from filter (which respects CLI --mode/--providers or config)
// This makes tabs dynamic based on what's actually enabled
let enabled = filter.borrow().enabled_providers();
let tab_strings: Vec<String> = enabled.iter().map(|p| p.to_string()).collect();
let tab_order = Rc::new(enabled);
// Create toggle buttons for each provider (from config)
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &cfg.general.tabs);
// Create toggle buttons for each enabled provider
let filter_buttons = Self::create_filter_buttons(&filter_tabs, &filter, &tab_strings);
let filter_buttons = Rc::new(RefCell::new(filter_buttons));
header_box.append(&mode_label);
header_box.append(&filter_tabs);
// Search entry with dynamic placeholder
let placeholder = Self::build_placeholder(&filter.borrow());
// Search entry with dynamic placeholder (or custom prompt if provided)
let placeholder = custom_prompt.clone().unwrap_or_else(|| Self::build_placeholder(&filter.borrow()));
let search_entry = Entry::builder()
.placeholder_text(&placeholder)
.hexpand(true)
@@ -175,6 +191,8 @@ impl MainWindow {
drop(cfg);
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
let main_window = Self {
window,
search_entry,
@@ -190,9 +208,12 @@ impl MainWindow {
filter_buttons,
submenu_state: Rc::new(RefCell::new(SubmenuState::default())),
tab_order,
custom_prompt,
lazy_state,
};
main_window.setup_signals();
main_window.setup_lazy_loading();
main_window.update_results("");
// Ensure search entry has focus when window is shown
@@ -232,11 +253,11 @@ impl MainWindow {
let mut results = current_results_for_auto.borrow_mut();
for type_id in &widget_ids {
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id) {
if let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id) {
existing.name = new_item.name;
existing.description = new_item.description;
}
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
&& let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
{
existing.name = new_item.name;
existing.description = new_item.description;
}
}
}
@@ -263,11 +284,21 @@ impl MainWindow {
}
};
let label = Self::provider_tab_label(&provider_type);
let base_label = Self::provider_tab_label(&provider_type);
// Show number hint in the label for first 9 tabs (using superscript)
let label = if idx < 9 {
let superscript = match idx + 1 {
1 => "¹", 2 => "²", 3 => "³", 4 => "", 5 => "",
6 => "", 7 => "", 8 => "", 9 => "", _ => "",
};
format!("{}{}", base_label, superscript)
} else {
base_label.to_string()
};
let shortcut = format!("Ctrl+{}", idx + 1);
let button = ToggleButton::builder()
.label(label)
.label(&label)
.tooltip_text(&shortcut)
.active(filter.borrow().is_enabled(provider_type.clone()))
.build();
@@ -525,6 +556,7 @@ impl MainWindow {
let mode_label = self.mode_label.clone();
let search_entry_for_change = self.search_entry.clone();
let submenu_state = self.submenu_state.clone();
let lazy_state = self.lazy_state.clone();
self.search_entry.connect_changed(move |entry| {
let raw_query = entry.text();
@@ -623,11 +655,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
for item in &results {
// Lazy loading: store all results but only display initial batch
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display only initial batch
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
results_list.append(&row);
}
@@ -636,7 +678,8 @@ impl MainWindow {
results_list.select_row(Some(&first_row));
}
*current_results.borrow_mut() = results;
// current_results holds only what's displayed (for selection/activation)
*current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
});
// Entry activate signal (Enter key in search entry)
@@ -847,6 +890,7 @@ impl MainWindow {
// Ctrl+1-9 toggle specific providers based on tab order (only when not in submenu)
Key::_1 | Key::_2 | Key::_3 | Key::_4 | Key::_5 |
Key::_6 | Key::_7 | Key::_8 | Key::_9 if ctrl => {
info!("[UI] Ctrl+number detected: {:?}", key);
if !submenu_state.borrow().active {
let idx = match key {
Key::_1 => 0,
@@ -860,7 +904,9 @@ impl MainWindow {
Key::_9 => 8,
_ => return gtk4::glib::Propagation::Proceed,
};
info!("[UI] Toggling tab at index {}", idx);
if let Some(provider) = tab_order.get(idx) {
info!("[UI] Found provider: {:?}", provider);
Self::toggle_provider_button(
provider.clone(),
&filter,
@@ -868,6 +914,8 @@ impl MainWindow {
&search_entry,
&mode_label,
);
} else {
info!("[UI] No provider at index {}, tab_order len={}", idx, tab_order.len());
}
}
gtk4::glib::Propagation::Stop
@@ -955,25 +1003,65 @@ impl MainWindow {
}
let current = filter.borrow().enabled_providers();
let all_enabled = current.len() == tab_order.len();
let next = if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
if forward {
tab_order[(idx + 1) % tab_order.len()].clone()
// Cycle: All -> Provider1 -> Provider2 -> ... -> ProviderN -> All
// In "All" mode (all providers enabled), we go to first provider (forward) or last (backward)
// In single-provider mode, we go to next provider or back to All at the boundary
if all_enabled {
// Currently showing all, go to first (forward) or last (backward) single provider
let next = if forward {
tab_order[0].clone()
} else {
tab_order[(idx + tab_order.len() - 1) % tab_order.len()].clone()
tab_order[tab_order.len() - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
} else if current.len() == 1 {
let idx = tab_order.iter().position(|p| p == &current[0]).unwrap_or(0);
let at_boundary = if forward { idx == tab_order.len() - 1 } else { idx == 0 };
if at_boundary {
// At boundary, go back to "All" mode
{
let mut f = filter.borrow_mut();
for provider in tab_order {
f.enable(provider.clone());
}
}
for (_, button) in buttons.borrow().iter() {
button.set_active(true);
}
} else {
// Move to next/previous provider
let next = if forward {
tab_order[idx + 1].clone()
} else {
tab_order[idx - 1].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
} else {
tab_order[0].clone()
};
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
// Some but not all providers enabled - go to first provider
let next = tab_order[0].clone();
{
let mut f = filter.borrow_mut();
f.set_single_mode(next.clone());
}
for (ptype, button) in buttons.borrow().iter() {
button.set_active(ptype == &next);
}
}
mode_label.set_label(filter.borrow().mode_display_name());
@@ -1009,6 +1097,7 @@ impl MainWindow {
let use_frecency = cfg.providers.frecency;
drop(cfg);
// Fetch all matching results (up to max_results)
let results: Vec<LaunchItem> = if use_frecency {
self.providers
.borrow_mut()
@@ -1025,11 +1114,21 @@ impl MainWindow {
.collect()
};
// Clear existing results
while let Some(child) = self.results_list.first_child() {
self.results_list.remove(&child);
}
for item in &results {
// Store all results for lazy loading
let initial_count = INITIAL_RESULTS.min(results.len());
{
let mut lazy = self.lazy_state.borrow_mut();
lazy.all_results = results.clone();
lazy.displayed_count = initial_count;
}
// Display initial batch only
for item in results.iter().take(initial_count) {
let row = ResultRow::new(item);
self.results_list.append(&row);
}
@@ -1038,7 +1137,74 @@ impl MainWindow {
self.results_list.select_row(Some(&first_row));
}
*self.current_results.borrow_mut() = results;
// current_results holds what's currently displayed
*self.current_results.borrow_mut() = results.into_iter().take(initial_count).collect();
}
/// Set up lazy loading scroll detection
fn setup_lazy_loading(&self) {
let vadj = self.scrolled.vadjustment();
let results_list = self.results_list.clone();
let lazy_state = self.lazy_state.clone();
let current_results = self.current_results.clone();
// Load more on scroll
vadj.connect_value_changed(move |adj| {
let value = adj.value();
let upper = adj.upper();
let page_size = adj.page_size();
// Load more when near bottom (within 50px)
let near_bottom = upper > page_size && (value + page_size >= upper - 50.0);
if near_bottom {
Self::load_more_items(&lazy_state, &results_list, &current_results);
}
});
// Also load more when selecting rows near the end (keyboard navigation)
let lazy_state2 = self.lazy_state.clone();
let results_list2 = self.results_list.clone();
let current_results2 = self.current_results.clone();
self.results_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let index = row.index();
let lazy = lazy_state2.borrow();
let displayed = lazy.displayed_count;
let all_count = lazy.all_results.len();
drop(lazy);
// Load more if within 3 items of the end
if displayed < all_count && (index as usize) >= displayed.saturating_sub(3) {
Self::load_more_items(&lazy_state2, &results_list2, &current_results2);
}
}
});
}
/// Load more items from lazy state
fn load_more_items(
lazy_state: &Rc<RefCell<LazyLoadState>>,
results_list: &ListBox,
current_results: &Rc<RefCell<Vec<LaunchItem>>>,
) {
let mut lazy = lazy_state.borrow_mut();
let all_count = lazy.all_results.len();
let displayed = lazy.displayed_count;
if displayed < all_count {
// Load next batch
let new_end = (displayed + LOAD_MORE_BATCH).min(all_count);
for item in lazy.all_results[displayed..new_end].iter() {
let row = ResultRow::new(item);
results_list.append(&row);
}
lazy.displayed_count = new_end;
// Update current_results
let mut current = current_results.borrow_mut();
current.extend(lazy.all_results[displayed..new_end].iter().cloned());
}
}
/// Handle item activation - returns true if window should close
@@ -1075,7 +1241,8 @@ impl MainWindow {
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
let cmd = if item.terminal {
format!("{} -e {}", config.general.terminal_command, item.command)
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
} else {
item.command.clone()
};

View File

@@ -7,6 +7,17 @@ pub struct ResultRow {
row: ListBoxRow,
}
/// Check if a string looks like an emoji (starts with a non-ASCII character
/// and is very short - typically 1-4 chars for complex emojis with ZWJ)
fn is_emoji_icon(s: &str) -> bool {
if s.is_empty() {
return false;
}
// Emojis are non-ASCII and typically very short (1-8 chars for complex ZWJ sequences)
let first_char = s.chars().next().unwrap();
!first_char.is_ascii() && s.chars().count() <= 8
}
impl ResultRow {
#[allow(clippy::new_ret_no_self)]
pub fn new(item: &LaunchItem) -> ListBoxRow {
@@ -26,46 +37,64 @@ impl ResultRow {
.margin_end(12)
.build();
// Icon - handle GResource paths, file paths, icon names, and fallbacks
// Icon - handle GResource paths, file paths, icon names, emojis, and fallbacks
let icon_widget: Widget = if let Some(icon_path) = &item.icon {
let img = if icon_path.starts_with("/org/owlry/launcher/icons/") {
if is_emoji_icon(icon_path) {
// Emoji character - display as text label
let emoji_label = Label::builder()
.label(icon_path)
.width_request(32)
.height_request(32)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.build();
emoji_label.add_css_class("owlry-result-icon");
emoji_label.add_css_class("owlry-emoji-icon");
emoji_label.upcast()
} else if icon_path.starts_with("/org/owlry/launcher/icons/") {
// GResource path - load from bundled resources
Image::from_resource(icon_path)
let img = Image::from_resource(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else if icon_path.starts_with('/') {
// Absolute file path
Image::from_file(icon_path)
let img = Image::from_file(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
} else {
// Icon theme name
Image::from_icon_name(icon_path)
};
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
let img = Image::from_icon_name(icon_path);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.upcast()
}
} else {
// Default icon based on provider type
// Default icon based on provider type (using symbolic icons for theme color support)
let default_icon = match &item.provider {
crate::providers::ProviderType::Application => "application-x-executable",
crate::providers::ProviderType::Bookmarks => "user-bookmarks",
crate::providers::ProviderType::Calculator => "accessories-calculator",
crate::providers::ProviderType::Clipboard => "edit-paste",
crate::providers::ProviderType::Command => "utilities-terminal",
crate::providers::ProviderType::Application => "application-x-executable-symbolic",
crate::providers::ProviderType::Bookmarks => "user-bookmarks-symbolic",
crate::providers::ProviderType::Calculator => "accessories-calculator-symbolic",
crate::providers::ProviderType::Clipboard => "edit-paste-symbolic",
crate::providers::ProviderType::Command => "utilities-terminal-symbolic",
crate::providers::ProviderType::Dmenu => "view-list-symbolic",
crate::providers::ProviderType::Emoji => "face-smile",
crate::providers::ProviderType::Files => "folder",
crate::providers::ProviderType::Scripts => "application-x-executable",
crate::providers::ProviderType::Ssh => "network-server",
crate::providers::ProviderType::System => "system-shutdown",
crate::providers::ProviderType::Uuctl => "system-run",
crate::providers::ProviderType::WebSearch => "web-browser",
// Widget providers now have icons set, but keep fallbacks
crate::providers::ProviderType::Emoji => "face-smile-symbolic",
crate::providers::ProviderType::Files => "folder-symbolic",
crate::providers::ProviderType::Scripts => "application-x-executable-symbolic",
crate::providers::ProviderType::Ssh => "network-server-symbolic",
crate::providers::ProviderType::System => "system-shutdown-symbolic",
crate::providers::ProviderType::Uuctl => "system-run-symbolic",
crate::providers::ProviderType::WebSearch => "web-browser-symbolic",
crate::providers::ProviderType::Weather => "weather-clear-symbolic",
crate::providers::ProviderType::MediaPlayer => "media-playback-start-symbolic",
crate::providers::ProviderType::Pomodoro => "alarm-symbolic",
crate::providers::ProviderType::Plugin(_) => "application-x-addon",
crate::providers::ProviderType::Plugin(_) => "application-x-addon-symbolic",
};
let img = Image::from_icon_name(default_icon);
img.set_pixel_size(32);
img.add_css_class("owlry-result-icon");
img.add_css_class("owlry-symbolic-icon");
img.upcast()
};

344
data/themes/apex-neon.css Normal file
View File

@@ -0,0 +1,344 @@
/*
* Owlry - Apex Neon Theme
* "State over Decoration."
*
* A high-contrast dark theme built for focus and clinical clarity.
* Color exists to signal STATE, not to decorate space.
*
* Author: S0wlz (Owlibou)
*
* ─────────────────────────────────────────────────────────────────
* APEX DNA - Semantic Color Roles:
*
* RED is the Predator: Active intent, cursor, current location, critical errors
* CYAN is Informational: Technical data, links, neutral highlights
* PURPLE is Sacred: Root access, special modes, exceptional states
* GREEN is Success: Completion, OK states, positive feedback
* YELLOW is Warning: Caution, load states, attention needed
*
* Rule: If a UI element is not important, it does not glow.
* ─────────────────────────────────────────────────────────────────
*
* Core Palette:
* - Void Black: #050505 (absolute background)
* - Dark Surface: #141414 (inputs, inactive elements)
* - Light Surface: #262626 (separators, borders)
* - Stark White: #ededed (primary text)
* - Muted: #737373 (secondary text)
* - Razor Red: #ff0044 (THE accent - focus, cursor, selection)
* - Electric Cyan: #00eaff (info, links, technical)
* - Sacred Purple: #9d00ff (special, root, elevated)
* - Neon Green: #00ff99 (success, OK)
* - Warning Yellow: #ffb700 (warning, caution)
*
* Bright Escalations:
* - Alert Red: #ff8899 (distinguishable from cursor)
* - Active Cyan: #5af3ff (active info)
* - Active Green: #2bffb2 (active success)
* - Urgent Yellow: #ffd24d (urgent warning)
* - Elevated Purple:#c84dff (elevated special)
*
* Usage: Set theme = "apex-neon" in config.toml
*/
:root {
/* Core surfaces */
--owlry-bg: #050505;
--owlry-bg-secondary: #141414;
--owlry-border: #262626;
--owlry-text: #ededed;
--owlry-text-secondary: #737373;
/* The Predator - primary accent */
--owlry-accent: #ff0044;
--owlry-accent-bright: #ff8899;
/* Provider badges - mapped to Apex semantics */
--owlry-badge-app: #00eaff; /* Cyan: apps are informational */
--owlry-badge-bookmark: #ffb700; /* Yellow: bookmarks need attention */
--owlry-badge-calc: #ffd24d; /* Bright Yellow: calculator results */
--owlry-badge-clip: #9d00ff; /* Purple: clipboard is special */
--owlry-badge-cmd: #9d00ff; /* Purple: commands are elevated */
--owlry-badge-dmenu: #00ff99; /* Green: dmenu is success/pipe */
--owlry-badge-emoji: #c84dff; /* Bright Purple: emoji is special */
--owlry-badge-file: #5af3ff; /* Bright Cyan: file search is active info */
--owlry-badge-script: #2bffb2; /* Bright Green: scripts execute successfully */
--owlry-badge-ssh: #00eaff; /* Cyan: SSH is technical/info */
--owlry-badge-sys: #ff0044; /* Red: system actions are critical */
--owlry-badge-uuctl: #ffb700; /* Yellow: uuctl requires attention */
--owlry-badge-web: #00eaff; /* Cyan: web is informational */
/* Widget badges */
--owlry-badge-media: #c84dff; /* Bright Purple: media is special */
--owlry-badge-weather: #5af3ff; /* Bright Cyan: weather is active info */
--owlry-badge-pomo: #ff8899; /* Alert Red: pomodoro demands attention */
}
.owlry-main {
background-color: rgba(5, 5, 5, 0.98);
border: 1px solid rgba(38, 38, 38, 0.8);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 0, 68, 0.1);
}
.owlry-search {
background-color: rgba(20, 20, 20, 0.9);
border: 2px solid rgba(38, 38, 38, 0.8);
color: var(--owlry-text);
caret-color: var(--owlry-accent);
}
.owlry-search:focus {
border-color: var(--owlry-accent);
box-shadow: 0 0 0 2px rgba(255, 0, 68, 0.3);
}
.owlry-result-row:hover {
background-color: rgba(20, 20, 20, 0.8);
}
.owlry-result-row:selected {
background-color: rgba(255, 0, 68, 0.15);
border-left: 3px solid var(--owlry-accent);
}
.owlry-result-row:selected .owlry-result-name {
color: var(--owlry-accent-bright);
}
.owlry-result-row:selected .owlry-result-icon {
color: var(--owlry-accent);
}
/* Provider badges - styled per Apex semantics */
.owlry-badge-app {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
}
.owlry-badge-bookmark {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
}
.owlry-badge-calc {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
}
.owlry-badge-clip {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
}
.owlry-badge-cmd {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
}
.owlry-badge-dmenu {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
}
.owlry-badge-emoji {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
}
.owlry-badge-file {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
}
.owlry-badge-script {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
}
.owlry-badge-ssh {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
}
.owlry-badge-sys {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
}
.owlry-badge-uuctl {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
}
.owlry-badge-web {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
}
/* Widget badges */
.owlry-badge-media {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
}
.owlry-badge-weather {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
}
.owlry-badge-pomo {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
}
/* Filter button - default uses The Predator */
.owlry-filter-button:checked {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border-color: rgba(255, 0, 68, 0.5);
}
/* Provider-specific filter buttons - follow Apex semantics */
.owlry-filter-app:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-app);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-bookmark:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-bookmark);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-calc:checked {
background-color: rgba(255, 210, 77, 0.15);
color: var(--owlry-badge-calc);
border-color: rgba(255, 210, 77, 0.5);
}
.owlry-filter-clip:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-clip);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-cmd:checked {
background-color: rgba(157, 0, 255, 0.15);
color: var(--owlry-badge-cmd);
border-color: rgba(157, 0, 255, 0.5);
}
.owlry-filter-dmenu:checked {
background-color: rgba(0, 255, 153, 0.15);
color: var(--owlry-badge-dmenu);
border-color: rgba(0, 255, 153, 0.5);
}
.owlry-filter-emoji:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-emoji);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-file:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-file);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-script:checked {
background-color: rgba(43, 255, 178, 0.15);
color: var(--owlry-badge-script);
border-color: rgba(43, 255, 178, 0.5);
}
.owlry-filter-ssh:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-ssh);
border-color: rgba(0, 234, 255, 0.5);
}
.owlry-filter-sys:checked {
background-color: rgba(255, 0, 68, 0.15);
color: var(--owlry-badge-sys);
border-color: rgba(255, 0, 68, 0.5);
}
.owlry-filter-uuctl:checked {
background-color: rgba(255, 183, 0, 0.15);
color: var(--owlry-badge-uuctl);
border-color: rgba(255, 183, 0, 0.5);
}
.owlry-filter-web:checked {
background-color: rgba(0, 234, 255, 0.15);
color: var(--owlry-badge-web);
border-color: rgba(0, 234, 255, 0.5);
}
/* Widget filter buttons */
.owlry-filter-media:checked {
background-color: rgba(200, 77, 255, 0.15);
color: var(--owlry-badge-media);
border-color: rgba(200, 77, 255, 0.5);
}
.owlry-filter-weather:checked {
background-color: rgba(90, 243, 255, 0.15);
color: var(--owlry-badge-weather);
border-color: rgba(90, 243, 255, 0.5);
}
.owlry-filter-pomodoro:checked {
background-color: rgba(255, 136, 153, 0.15);
color: var(--owlry-badge-pomo);
border-color: rgba(255, 136, 153, 0.5);
}
/* Scrollbar - subtle in Void, The Predator on active */
scrollbar slider {
background-color: rgba(38, 38, 38, 0.8);
}
scrollbar slider:hover {
background-color: rgba(64, 64, 64, 0.9);
}
scrollbar slider:active {
background-color: var(--owlry-accent);
}
/* Text selection - Apex Hard Rule: black text on red (target locked) */
selection {
background-color: var(--owlry-accent);
color: #050505;
}
/* Mode indicator - The Predator marks current mode */
.owlry-mode-indicator {
background-color: rgba(255, 0, 68, 0.2);
color: var(--owlry-accent);
border: 1px solid rgba(255, 0, 68, 0.3);
}
/* Hints bar */
.owlry-hints {
border-top: 1px solid rgba(38, 38, 38, 0.8);
}
.owlry-hints-label {
color: var(--owlry-text-secondary);
}
/* Tag badges in results */
.owlry-tag-badge {
background-color: rgba(38, 38, 38, 0.6);
color: var(--owlry-text-secondary);
}
.owlry-result-row:selected .owlry-tag-badge {
background-color: rgba(255, 136, 153, 0.25);
color: var(--owlry-accent-bright);
}

View File

@@ -60,6 +60,10 @@ install-local:
sudo mkdir -p /usr/lib/owlry/plugins
sudo mkdir -p /usr/lib/owlry/runtimes
echo "Cleaning up stale files..."
# Remove runtime files that may have ended up in plugins dir (from old installs)
sudo rm -f /usr/lib/owlry/plugins/libowlry_lua.so /usr/lib/owlry/plugins/libowlry_rune.so
echo "Installing core binary..."
sudo install -Dm755 target/release/owlry /usr/bin/owlry