diff --git a/Cargo.lock b/Cargo.lock index 075903d..f5a7f0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -161,9 +161,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -177,9 +177,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -188,9 +188,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -198,9 +198,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -222,9 +222,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "convert_case" @@ -447,9 +447,9 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", "objc2", @@ -766,9 +766,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -796,9 +796,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jaq-core" @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "jnv" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "arboard", @@ -857,7 +857,9 @@ dependencies = [ "jaq-json", "jaq-std", "promkit-widgets", + "rustix", "serde", + "termcfg", "tokio", "tokio-stream", "toml", @@ -865,9 +867,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -875,9 +877,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -887,19 +889,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" @@ -952,9 +953,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -980,9 +981,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1053,9 +1054,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1121,9 +1122,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" @@ -1155,9 +1156,9 @@ dependencies = [ [[package]] name = "promkit-core" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed89f85398b2590095afe8fb4852c177d09f568836c206a9bed823bf1e70a051" +checksum = "26da2c38cb21f2c5470a00868b513916d904f3fd6aef947962a5a06350651c49" dependencies = [ "anyhow", "crossbeam-skiplist", @@ -1168,26 +1169,24 @@ dependencies = [ [[package]] name = "promkit-widgets" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7ef81079760b198d5dde773c78b94a72edc2ebd057be386382c379e0d854fb6" +checksum = "2863f757bfc30b08ba1e0f54c7e1fe3ea62b46ad44fa82cff0849f1293f29188" dependencies = [ "anyhow", "promkit-core", "rayon", "serde", "serde_json", + "termcfg", "tokio", ] [[package]] name = "pxfm" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" [[package]] name = "quick-error" @@ -1197,18 +1196,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1270,9 +1269,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1345,9 +1344,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -1409,12 +1408,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1434,6 +1433,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termcfg" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e444ec0de07571e26f31f6ac5006c03540b61d35bec105031e82c6d6cbb5fab" +dependencies = [ + "crossterm", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1476,9 +1486,9 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", @@ -1490,9 +1500,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -1507,9 +1517,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -1539,7 +1549,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1553,18 +1563,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tree_magic_mini" @@ -1591,9 +1601,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" @@ -1621,9 +1631,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -1634,9 +1644,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1644,9 +1654,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -1657,18 +1667,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" dependencies = [ "cc", "downcast-rs", @@ -1679,9 +1689,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ "bitflags", "rustix", @@ -1691,9 +1701,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ "bitflags", "wayland-backend", @@ -1703,9 +1713,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ "bitflags", "wayland-backend", @@ -1716,9 +1726,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", "quick-xml", @@ -1727,9 +1737,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" dependencies = [ "pkg-config", ] @@ -1906,9 +1916,15 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" [[package]] name = "wl-clipboard-rs" @@ -1947,18 +1963,18 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", @@ -1973,15 +1989,15 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zune-core" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index ba27d7b..ace8337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jnv" -version = "0.6.2" +version = "0.7.0" authors = ["ynqa "] edition = "2021" description = "JSON navigator and interactive filter leveraging jq" @@ -12,14 +12,15 @@ readme = "README.md" anyhow = "1.0.102" arboard = { version = "3.6.1", features = ["wayland-data-control"] } async-trait = "0.1.89" -clap = { version = "4.5.60", features = ["derive"] } +clap = { version = "4.6.0", features = ["derive"] } duration-string = { version = "0.5.3", features = ["serde"] } derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" -promkit-widgets = { version = "0.2.0", features = ["jsonstream", "listbox", "text", "texteditor"] } +rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" -tokio = { version = "1.49.0", features = ["full"] } +termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } +tokio = { version = "1.50.0", features = ["full"] } tokio-stream = "0.1.18" toml = "0.9.8" @@ -28,6 +29,11 @@ jaq-core = "2.2.1" jaq-json = { version = "1.1.3", features = ["serde_json"] } jaq-std = "2.1.2" +[dependencies.promkit-widgets] +version = "0.5.0" +features = ["jsonstream", "listbox", "serde", "spinner", "status", "texteditor"] +default-features = false + # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" diff --git a/README.md b/README.md index e8fd8ad..e1edbf1 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,14 @@ cargo install jnv ```bash cat data.json | jnv + # or jnv data.json + +# or write current result to stdout on exit (UNIX only) +cat data.json | jnv --write-to-stdout | some-command +# and also output to file +cat data.json | jnv -- --write-to-stdout > result.json ``` ## Keymap @@ -180,6 +186,7 @@ Arguments: Options: -c, --config Path to the configuration file. --default-filter Default jq filter to apply to the input data + --write-to-stdout Write the current JSON result to stdout when exiting -h, --help Print help (see more with '--help') -V, --version Print version ``` @@ -204,99 +211,222 @@ the default configuration file location for each platform is as follows: If the configuration file does not exist, it will be automatically created on first run. -### Configuration Options +### Configuration -The following settings are available in `config.toml`: +> [!IMPORTANT] +> The syntax in TOML configurations +> like [default.toml](./default.toml) was revamped in v0.7.0, +> and the configuration shown below reflects the new format. +> A migration tool is not provided for this change. +> Please manually replace/update your local +> `config.toml` to match the new syntax. + +> [!WARNING] +> Depending on the type of terminal and environment, +> characters and styles may not be displayed properly. +> Specific key bindings and decorative characters may not +> display or function correctly in certain terminal emulators. + +
+The following settings are available in config.toml ```toml -# Whether to hide the hint message +# Whether to hide hint messages no_hint = false # Editor settings -[editor] -# Editor mode ("Insert" or "Overwrite") -mode = "Insert" -# Word break characters +# Uses promkit_widgets::text_editor::Config directly +[editor.on_focus] + +# Editor mode +# "Insert": Insert characters at the cursor position +# "Overwrite": Replace characters at the cursor position with new ones +edit_mode = "Insert" + +# Characters considered as word boundaries +# These are used to define word movement and deletion behavior in the editor word_break_chars = [".", "|", "(", ")", "[", "]"] -# Theme when editor is focused -[editor.theme_on_focus] +# Style notation (termcfg) +# Format: "fg=,bg=,ul=,attr=" +# Examples: +# - "fg=blue" +# - "fg=#00FF00,bg=black,attr=bold|underlined" +# - "attr=dim" +# +# Color tokens: +# - reset, black, red, green, yellow, blue, magenta, cyan, white +# - darkgrey, darkred, darkgreen, darkyellow, darkblue, darkmagenta, darkcyan, grey +# - #RRGGBB +# +# Attribute tokens (examples): +# - bold, italic, underlined, dim, reverse, crossedout, nounderline, nobold +# +# Notes: +# - ANSI 256-color index tokens (0..255, e.g. "200") are currently out of notation scope. +# - See termcfg notation reference for full token list. +# +# References: +# - https://github.com/ynqa/termcfg/blob/main/Notations.md +# - https://github.com/ynqa/termcfg + +# Prefix shown before the cursor prefix = "❯❯ " -prefix_style = { foreground = "blue" } -active_char_style = { background = "magenta" } -inactive_char_style = {} - -# Theme when editor is not focused -[editor.theme_on_defocus] +# Style for the prefix +prefix_style = "fg=blue" +# Style for the character under the cursor +active_char_style = "bg=magenta" +# Style for all other characters +inactive_char_style = "" + +# Theme settings when the editor is unfocused +[editor.on_defocus] +# Prefix shown when focus is lost prefix = "▼ " -prefix_style = { foreground = "blue", attributes = ["Dim"] } -active_char_style = { attributes = ["Dim"] } -inactive_char_style = { attributes = ["Dim"] } +# Style for the prefix when unfocused +prefix_style = "fg=blue,attr=dim" +# Style for the character under the cursor when unfocused +active_char_style = "attr=dim" +# Style for all other characters when unfocused +inactive_char_style = "attr=dim" # JSON display settings [json] -# Maximum number of JSON objects to read from stream -# max_streams = +# Maximum number of JSON objects to read from streams (e.g., JSON Lines format) +# Limits how many objects are processed to reduce memory usage when handling large data streams +# No limit if unset +# max_streams = -# JSON theme settings -[json.theme] +# JSON display settings +# Uses promkit_widgets::jsonstream::Config directly +[json.stream] +# Number of spaces to use for indentation indent = 2 -curly_brackets_style = { attributes = ["Bold"] } -square_brackets_style = { attributes = ["Bold"] } -key_style = { foreground = "cyan" } -string_value_style = { foreground = "green" } -number_value_style = {} -boolean_value_style = {} -null_value_style = { foreground = "grey" } +# Style for curly brackets {} +curly_brackets_style = "attr=bold" +# Style for square brackets [] +square_brackets_style = "attr=bold" +# Style for JSON keys +key_style = "fg=cyan" +# Style for string values +string_value_style = "fg=green" +# Style for number values +number_value_style = "" +# Style for boolean values +boolean_value_style = "" +# Style for null values +null_value_style = "fg=grey" +# Attribute for the selected row and unselected rows +active_item_attribute = "bold" +# Attribute for unselected rows +inactive_item_attribute = "dim" +# Behavior when JSON content exceeds the available width +# "Wrap": Wrap content to the next line +# "Truncate": Truncate content with an ellipsis (...) +overflow_mode = "Wrap" # Completion feature settings [completion] -lines = 3 -cursor = "❯ " -active_item_style = { foreground = "grey", background = "yellow" } -inactive_item_style = { foreground = "grey" } +# Settings for background loading of completion candidates +# +# Number of candidates loaded per chunk for search results +# A larger value displays results faster but uses more memory search_result_chunk_size = 100 + +# Number of items loaded per batch during background loading +# A larger value finishes loading sooner but uses more memory temporarily search_load_chunk_size = 50000 -# Keybind settings +# Completion UI settings +# Uses promkit_widgets::listbox::Config directly +[completion.listbox] +# Number of lines to display for completion candidates +lines = 3 +# Cursor character shown before the selected candidate +cursor = "❯ " +# Style for the selected candidate +active_item_style = "fg=grey,bg=yellow" +# Style for unselected candidates +inactive_item_style = "fg=grey" + +# Keybinding settings [keybinds] -# Application exit key -exit = [{ Key = { modifiers = "CONTROL", code = { Char = "c" } } }] -# Copy query to clipboard key -copy_query = [{ Key = { modifiers = "CONTROL", code = { Char = "q" } } }] -# Copy result to clipboard key -copy_result = [{ Key = { modifiers = "CONTROL", code = { Char = "o" } } }] -# Mode switch keys -switch_mode = [ - { Key = { code = "Down", modifiers = "SHIFT" } }, - { Key = { code = "Up", modifiers = "SHIFT" } } -] - -# Editor operation keybinds +# Key to exit the application +exit = ["Ctrl+C"] +# Key to copy the query to the clipboard +copy_query = ["Ctrl+Q"] +# Key to copy the result to the clipboard +copy_result = ["Ctrl+O"] +# Keys to switch focus between editor and JSON viewer +switch_mode = ["Shift+Down", "Shift+Up"] + +# Keybindings for editor operations [keybinds.on_editor] -# (Details omitted) - -# JSON viewer keybinds +# Move cursor left +backward = ["Left"] + +# Move cursor right +forward = ["Right"] + +# Move cursor to beginning of line +move_to_head = ["Ctrl+A"] +# Move cursor to end of line +move_to_tail = ["Ctrl+E"] +# Move cursor to previous word boundary +move_to_previous_nearest = ["Alt+B"] +# Move cursor to next word boundary +move_to_next_nearest = ["Alt+F"] +# Delete character at the cursor +erase = ["Backspace"] + +# Delete all input +erase_all = ["Ctrl+U"] + +# Delete from cursor to previous word boundary +erase_to_previous_nearest = ["Ctrl+W"] +# Delete from cursor to next word boundary +erase_to_next_nearest = ["Alt+D"] +# Trigger completion +completion = ["Tab"] +# Move up in the completion list +on_completion.up = ["Up"] +# Move down in the completion list +on_completion.down = ["Down", "Tab"] + +# Keybindings for JSON viewer operations [keybinds.on_json_viewer] -# (Details omitted) +# Move up in JSON viewer +up = ["Up", "Ctrl+K", "ScrollUp"] +# Move down in JSON viewer +down = ["Down", "Ctrl+J", "ScrollDown"] +# Move to the top of JSON viewer +move_to_head = ["Ctrl+L"] +# Move to the bottom of JSON viewer +move_to_tail = ["Ctrl+H"] +# Toggle expand/collapse of JSON nodes +toggle = ["Enter"] +# Expand all JSON nodes +expand = ["Ctrl+P"] +# Collapse all JSON nodes +collapse = ["Ctrl+N"] # Application reactivity settings [reactivity_control] -# Delay time after query input +# Delay before processing query input +# Prevents excessive updates while user is typing query_debounce_duration = "600ms" -# Redraw delay time after window resize + +# Delay before redrawing after window resize +# Prevents frequent redraws during continuous resizing resize_debounce_duration = "200ms" -# Spinner animation update interval + +# Interval for spinner animation updates +# Controls the speed of the loading spinner spin_duration = "300ms" ``` -For more details on configuration, please refer to [default.toml](./default.toml) - -> [!WARNING] -> Depending on the type of terminal and environment, -> characters and styles may not be displayed properly. -> Specific key bindings and decorative characters may not -> display or function correctly in certain terminal emulators. +
## Stargazers over time + [![Stargazers over time](https://starchart.cc/ynqa/jnv.svg?variant=adaptive)](https://starchart.cc/ynqa/jnv) diff --git a/default.toml b/default.toml index b0b2327..05f892e 100644 --- a/default.toml +++ b/default.toml @@ -2,54 +2,60 @@ no_hint = false # Editor settings -[editor] +# Uses promkit_widgets::text_editor::Config directly +[editor.on_focus] + # Editor mode # "Insert": Insert characters at the cursor position # "Overwrite": Replace characters at the cursor position with new ones -mode = "Insert" +edit_mode = "Insert" # Characters considered as word boundaries # These are used to define word movement and deletion behavior in the editor word_break_chars = [".", "|", "(", ")", "[", "]"] -# How to configure colors and text attributes -# -# Color specification methods: -# 1. By name: "black", "red", etc. -# 2. By RGB value: "rgb_(255,0,0)" or "#ff0000" -# 3. By ANSI value: "ansi_(16)" +# Style notation (termcfg) +# Format: "fg=,bg=,ul=,attr=" +# Examples: +# - "fg=blue" +# - "fg=#00FF00,bg=black,attr=bold|underlined" +# - "attr=dim" +# +# Color tokens: +# - reset, black, red, green, yellow, blue, magenta, cyan, white +# - darkgrey, darkred, darkgreen, darkyellow, darkblue, darkmagenta, darkcyan, grey +# - #RRGGBB # -# Text attribute specification: -# attributes = ["Bold"], etc. +# Attribute tokens (examples): +# - bold, italic, underlined, dim, reverse, crossedout, nounderline, nobold # -# Configuration example: -# style = { foreground = "blue", background = "magenta", attributes = ["Bold"] } +# Notes: +# - ANSI 256-color index tokens (0..255, e.g. "200") are currently out of notation scope. +# - See termcfg notation reference for full token list. # -# Detailed information: -# - Color: https://docs.rs/crossterm/0.28.1/crossterm/style/enum.Color.html -# - Attribute: https://docs.rs/crossterm/0.28.1/crossterm/style/enum.Attribute.html +# References: +# - https://github.com/ynqa/termcfg/blob/main/Notations.md +# - https://github.com/ynqa/termcfg -# Theme settings when the editor is focused -[editor.theme_on_focus] # Prefix shown before the cursor prefix = "❯❯ " # Style for the prefix -prefix_style = { foreground = "blue" } +prefix_style = "fg=blue" # Style for the character under the cursor -active_char_style = { background = "magenta" } +active_char_style = "bg=magenta" # Style for all other characters -inactive_char_style = {} +inactive_char_style = "" # Theme settings when the editor is unfocused -[editor.theme_on_defocus] +[editor.on_defocus] # Prefix shown when focus is lost prefix = "▼ " # Style for the prefix when unfocused -prefix_style = { foreground = "blue", attributes = ["Dim"] } +prefix_style = "fg=blue,attr=dim" # Style for the character under the cursor when unfocused -active_char_style = { attributes = ["Dim"] } +active_char_style = "attr=dim" # Style for all other characters when unfocused -inactive_char_style = { attributes = ["Dim"] } +inactive_char_style = "attr=dim" # JSON display settings [json] @@ -58,36 +64,36 @@ inactive_char_style = { attributes = ["Dim"] } # No limit if unset # max_streams = -# JSON display theme -[json.theme] +# JSON display settings +# Uses promkit_widgets::jsonstream::Config directly +[json.stream] # Number of spaces to use for indentation indent = 2 # Style for curly brackets {} -curly_brackets_style = { attributes = ["Bold"] } +curly_brackets_style = "attr=bold" # Style for square brackets [] -square_brackets_style = { attributes = ["Bold"] } +square_brackets_style = "attr=bold" # Style for JSON keys -key_style = { foreground = "cyan" } +key_style = "fg=cyan" # Style for string values -string_value_style = { foreground = "green" } +string_value_style = "fg=green" # Style for number values -number_value_style = {} +number_value_style = "" # Style for boolean values -boolean_value_style = {} +boolean_value_style = "" # Style for null values -null_value_style = { foreground = "grey" } +null_value_style = "fg=grey" +# Attribute for the selected row and unselected rows +active_item_attribute = "bold" +# Attribute for unselected rows +inactive_item_attribute = "dim" +# Behavior when JSON content exceeds the available width +# "Wrap": Wrap content to the next line +# "Truncate": Truncate content with an ellipsis (...) +overflow_mode = "Wrap" # Completion feature settings [completion] -# Number of lines to display for completion candidates -lines = 3 -# Cursor character shown before the selected candidate -cursor = "❯ " -# Style for the selected candidate -active_item_style = { foreground = "grey", background = "yellow" } -# Style for unselected candidates -inactive_item_style = { foreground = "grey" } - # Settings for background loading of completion candidates # # Number of candidates loaded per chunk for search results @@ -98,114 +104,78 @@ search_result_chunk_size = 100 # A larger value finishes loading sooner but uses more memory temporarily search_load_chunk_size = 50000 +# Completion UI settings +# Uses promkit_widgets::listbox::Config directly +[completion.listbox] +# Number of lines to display for completion candidates +lines = 3 +# Cursor character shown before the selected candidate +cursor = "❯ " +# Style for the selected candidate +active_item_style = "fg=grey,bg=yellow" +# Style for unselected candidates +inactive_item_style = "fg=grey" + # Keybinding settings [keybinds] # Key to exit the application -exit = [ - { Key = { modifiers = "CONTROL", code = { Char = "c" } } } -] +exit = ["Ctrl+C"] # Key to copy the query to the clipboard -copy_query = [ - { Key = { modifiers = "CONTROL", code = { Char = "q" } } } -] +copy_query = ["Ctrl+Q"] # Key to copy the result to the clipboard -copy_result = [ - { Key = { modifiers = "CONTROL", code = { Char = "o" } } } -] +copy_result = ["Ctrl+O"] # Keys to switch focus between editor and JSON viewer -switch_mode = [ - { Key = { code = "Down", modifiers = "SHIFT" } }, - { Key = { code = "Up", modifiers = "SHIFT" } } -] +switch_mode = ["Shift+Down", "Shift+Up"] # Keybindings for editor operations [keybinds.on_editor] # Move cursor left -backward = [ - { Key = { code = "Left", modifiers = "" } } -] +backward = ["Left"] + # Move cursor right -forward = [ - { Key = { code = "Right", modifiers = "" } } -] +forward = ["Right"] + # Move cursor to beginning of line -move_to_head = [ - { Key = { modifiers = "CONTROL", code = { Char = "a" } } } -] +move_to_head = ["Ctrl+A"] # Move cursor to end of line -move_to_tail = [ - { Key = { modifiers = "CONTROL", code = { Char = "e" } } } -] +move_to_tail = ["Ctrl+E"] # Move cursor to previous word boundary -move_to_previous_nearest = [ - { Key = { modifiers = "ALT", code = { Char = "b" } } } -] +move_to_previous_nearest = ["Alt+B"] # Move cursor to next word boundary -move_to_next_nearest = [ - { Key = { modifiers = "ALT", code = { Char = "f" } } } -] +move_to_next_nearest = ["Alt+F"] # Delete character at the cursor -erase = [ - { Key = { code = "Backspace", modifiers = "" } } -] +erase = ["Backspace"] + # Delete all input -erase_all = [ - { Key = { modifiers = "CONTROL", code = { Char = "u" } } } -] +erase_all = ["Ctrl+U"] + # Delete from cursor to previous word boundary -erase_to_previous_nearest = [ - { Key = { modifiers = "CONTROL", code = { Char = "w" } } } -] +erase_to_previous_nearest = ["Ctrl+W"] # Delete from cursor to next word boundary -erase_to_next_nearest = [ - { Key = { modifiers = "ALT", code = { Char = "d" } } } -] +erase_to_next_nearest = ["Alt+D"] # Trigger completion -completion = [ - { Key = { code = "Tab", modifiers = "" } } -] +completion = ["Tab"] # Move up in the completion list -on_completion.up = [ - { Key = { code = "Up", modifiers = "" } } -] +on_completion.up = ["Up"] # Move down in the completion list -on_completion.down = [ - { Key = { code = "Down", modifiers = "" } }, - { Key = { code = "Tab", modifiers = "" } } -] +on_completion.down = ["Down", "Tab"] # Keybindings for JSON viewer operations [keybinds.on_json_viewer] # Move up in JSON viewer -up = [ - { Key = { code = "Up", modifiers = "" } }, - { Key = { modifiers = "CONTROL", code = { Char = "k" } } } -] +up = ["Up", "Ctrl+K", "ScrollUp"] # Move down in JSON viewer -down = [ - { Key = { modifiers = "CONTROL", code = { Char = "j" } } }, - { Key = { code = "Down", modifiers = "" } } -] +down = ["Down", "Ctrl+J", "ScrollDown"] # Move to the top of JSON viewer -move_to_head = [ - { Key = { modifiers = "CONTROL", code = { Char = "l" } } } -] +move_to_head = ["Ctrl+L"] # Move to the bottom of JSON viewer -move_to_tail = [ - { Key = { modifiers = "CONTROL", code = { Char = "h" } } } -] +move_to_tail = ["Ctrl+H"] # Toggle expand/collapse of JSON nodes -toggle = [ - { Key = { code = "Enter", modifiers = "" } } -] +toggle = ["Enter"] # Expand all JSON nodes -expand = [ - { Key = { modifiers = "CONTROL", code = { Char = "p" } } } -] +expand = ["Ctrl+P"] # Collapse all JSON nodes -collapse = [ - { Key = { modifiers = "CONTROL", code = { Char = "n" } } } -] +collapse = ["Ctrl+N"] # Application reactivity settings [reactivity_control] diff --git a/src/config.rs b/src/config.rs index a8009f9..fb8ee63 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,313 +1,98 @@ use std::collections::HashSet; -use promkit_widgets::{ - core::crossterm::{ - event::{KeyCode, KeyModifiers}, - style::{Attribute, Attributes, Color, ContentStyle}, - }, - text_editor::Mode, -}; +use promkit_widgets::{core::crossterm::event::Event, jsonstream, listbox, text_editor}; use serde::{Deserialize, Serialize}; +use termcfg::crossterm_config::event_set_serde; use tokio::time::Duration; -mod content_style; -use content_style::content_style_serde; mod duration; use duration::duration_serde; -pub mod event; -use event::{EventDef, EventDefSet, KeyEventDef}; -mod text_editor; -use text_editor::text_editor_mode_serde; #[derive(Serialize, Deserialize)] pub struct EditorConfig { - pub theme_on_focus: EditorTheme, - pub theme_on_defocus: EditorTheme, - #[serde(with = "text_editor_mode_serde")] - pub mode: Mode, - pub word_break_chars: HashSet, -} - -#[derive(Serialize, Deserialize)] -pub struct EditorTheme { - pub prefix: String, - - #[serde(with = "content_style_serde")] - pub prefix_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub active_char_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub inactive_char_style: ContentStyle, -} - -impl Default for EditorConfig { - fn default() -> Self { - Self { - theme_on_focus: EditorTheme { - prefix: String::from("❯❯ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::Blue), - ..Default::default() - }, - active_char_style: ContentStyle { - background_color: Some(Color::Magenta), - ..Default::default() - }, - inactive_char_style: ContentStyle::default(), - }, - theme_on_defocus: EditorTheme { - prefix: String::from("▼ "), - prefix_style: ContentStyle { - foreground_color: Some(Color::Blue), - attributes: Attributes::from(Attribute::Dim), - ..Default::default() - }, - active_char_style: ContentStyle { - attributes: Attributes::from(Attribute::Dim), - ..Default::default() - }, - inactive_char_style: ContentStyle { - attributes: Attributes::from(Attribute::Dim), - ..Default::default() - }, - }, - mode: Mode::Insert, - word_break_chars: HashSet::from(['.', '|', '(', ')', '[', ']']), - } - } + pub on_focus: text_editor::Config, + pub on_defocus: text_editor::Config, } #[derive(Serialize, Deserialize)] pub struct JsonConfig { pub max_streams: Option, - pub theme: JsonTheme, -} - -#[derive(Serialize, Deserialize)] -pub struct JsonTheme { - pub indent: usize, - - #[serde(with = "content_style_serde")] - pub curly_brackets_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub square_brackets_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub key_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub string_value_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub number_value_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub boolean_value_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub null_value_style: ContentStyle, -} - -impl Default for JsonConfig { - fn default() -> Self { - Self { - max_streams: None, - theme: JsonTheme { - indent: 2, - curly_brackets_style: ContentStyle { - attributes: Attributes::from(Attribute::Bold), - ..Default::default() - }, - square_brackets_style: ContentStyle { - attributes: Attributes::from(Attribute::Bold), - ..Default::default() - }, - key_style: ContentStyle { - foreground_color: Some(Color::Cyan), - ..Default::default() - }, - string_value_style: ContentStyle { - foreground_color: Some(Color::Green), - ..Default::default() - }, - number_value_style: ContentStyle::default(), - boolean_value_style: ContentStyle::default(), - null_value_style: ContentStyle { - foreground_color: Some(Color::Grey), - ..Default::default() - }, - }, - } - } + pub stream: jsonstream::Config, } #[derive(Serialize, Deserialize)] pub struct CompletionConfig { - pub lines: Option, - pub cursor: String, + pub listbox: listbox::Config, pub search_result_chunk_size: usize, pub search_load_chunk_size: usize, - - #[serde(with = "content_style_serde")] - pub active_item_style: ContentStyle, - - #[serde(with = "content_style_serde")] - pub inactive_item_style: ContentStyle, -} - -impl Default for CompletionConfig { - fn default() -> Self { - Self { - lines: Some(3), - cursor: String::from("❯ "), - search_result_chunk_size: 100, - search_load_chunk_size: 50000, - active_item_style: ContentStyle { - foreground_color: Some(Color::Grey), - background_color: Some(Color::Yellow), - ..Default::default() - }, - inactive_item_style: ContentStyle { - foreground_color: Some(Color::Grey), - ..Default::default() - }, - } - } } // TODO: remove Clone derive #[derive(Clone, Serialize, Deserialize)] pub struct Keybinds { - pub exit: EventDefSet, - pub copy_query: EventDefSet, - pub copy_result: EventDefSet, - pub switch_mode: EventDefSet, + #[serde(with = "event_set_serde")] + pub exit: HashSet, + #[serde(with = "event_set_serde")] + pub copy_query: HashSet, + #[serde(with = "event_set_serde")] + pub copy_result: HashSet, + #[serde(with = "event_set_serde")] + pub switch_mode: HashSet, pub on_editor: EditorKeybinds, pub on_json_viewer: JsonViewerKeybinds, } #[derive(Clone, Serialize, Deserialize)] pub struct EditorKeybinds { - pub backward: EventDefSet, - pub forward: EventDefSet, - pub move_to_head: EventDefSet, - pub move_to_tail: EventDefSet, - pub move_to_previous_nearest: EventDefSet, - pub move_to_next_nearest: EventDefSet, - pub erase: EventDefSet, - pub erase_all: EventDefSet, - pub erase_to_previous_nearest: EventDefSet, - pub erase_to_next_nearest: EventDefSet, - pub completion: EventDefSet, + #[serde(with = "event_set_serde")] + pub backward: HashSet, + #[serde(with = "event_set_serde")] + pub forward: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_head: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_tail: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_previous_nearest: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_next_nearest: HashSet, + #[serde(with = "event_set_serde")] + pub erase: HashSet, + #[serde(with = "event_set_serde")] + pub erase_all: HashSet, + #[serde(with = "event_set_serde")] + pub erase_to_previous_nearest: HashSet, + #[serde(with = "event_set_serde")] + pub erase_to_next_nearest: HashSet, + #[serde(with = "event_set_serde")] + pub completion: HashSet, pub on_completion: CompletionKeybinds, } #[derive(Clone, Serialize, Deserialize)] pub struct CompletionKeybinds { - pub up: EventDefSet, - pub down: EventDefSet, + #[serde(with = "event_set_serde")] + pub up: HashSet, + #[serde(with = "event_set_serde")] + pub down: HashSet, } #[derive(Clone, Serialize, Deserialize)] pub struct JsonViewerKeybinds { - pub up: EventDefSet, - pub down: EventDefSet, - pub move_to_head: EventDefSet, - pub move_to_tail: EventDefSet, - pub toggle: EventDefSet, - pub expand: EventDefSet, - pub collapse: EventDefSet, -} - -impl Default for Keybinds { - fn default() -> Self { - Self { - exit: EventDefSet::from(KeyEventDef::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - copy_query: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('q'), - KeyModifiers::CONTROL, - )), - copy_result: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('o'), - KeyModifiers::CONTROL, - )), - switch_mode: EventDefSet::from_iter([ - EventDef::Key(KeyEventDef::new(KeyCode::Down, KeyModifiers::SHIFT)), - EventDef::Key(KeyEventDef::new(KeyCode::Up, KeyModifiers::SHIFT)), - ]), - on_editor: EditorKeybinds { - backward: EventDefSet::from(KeyEventDef::new(KeyCode::Left, KeyModifiers::NONE)), - forward: EventDefSet::from(KeyEventDef::new(KeyCode::Right, KeyModifiers::NONE)), - move_to_head: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('a'), - KeyModifiers::CONTROL, - )), - move_to_tail: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('e'), - KeyModifiers::CONTROL, - )), - move_to_next_nearest: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('f'), - KeyModifiers::ALT, - )), - move_to_previous_nearest: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('b'), - KeyModifiers::ALT, - )), - erase: EventDefSet::from(KeyEventDef::new(KeyCode::Backspace, KeyModifiers::NONE)), - erase_all: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('u'), - KeyModifiers::CONTROL, - )), - erase_to_previous_nearest: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('w'), - KeyModifiers::CONTROL, - )), - erase_to_next_nearest: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('d'), - KeyModifiers::ALT, - )), - completion: EventDefSet::from(KeyEventDef::new(KeyCode::Tab, KeyModifiers::NONE)), - on_completion: CompletionKeybinds { - up: EventDefSet::from(KeyEventDef::new(KeyCode::Up, KeyModifiers::NONE)), - down: EventDefSet::from_iter([ - EventDef::Key(KeyEventDef::new(KeyCode::Tab, KeyModifiers::NONE)), - EventDef::Key(KeyEventDef::new(KeyCode::Down, KeyModifiers::NONE)), - ]), - }, - }, - on_json_viewer: JsonViewerKeybinds { - up: EventDefSet::from_iter([ - EventDef::Key(KeyEventDef::new(KeyCode::Char('k'), KeyModifiers::CONTROL)), - EventDef::Key(KeyEventDef::new(KeyCode::Up, KeyModifiers::NONE)), - ]), - down: EventDefSet::from_iter([ - EventDef::Key(KeyEventDef::new(KeyCode::Char('j'), KeyModifiers::CONTROL)), - EventDef::Key(KeyEventDef::new(KeyCode::Down, KeyModifiers::NONE)), - ]), - move_to_head: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('l'), - KeyModifiers::CONTROL, - )), - move_to_tail: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('h'), - KeyModifiers::CONTROL, - )), - toggle: EventDefSet::from(KeyEventDef::new(KeyCode::Enter, KeyModifiers::NONE)), - expand: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('p'), - KeyModifiers::CONTROL, - )), - collapse: EventDefSet::from(KeyEventDef::new( - KeyCode::Char('n'), - KeyModifiers::CONTROL, - )), - }, - } - } + #[serde(with = "event_set_serde")] + pub up: HashSet, + #[serde(with = "event_set_serde")] + pub down: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_head: HashSet, + #[serde(with = "event_set_serde")] + pub move_to_tail: HashSet, + #[serde(with = "event_set_serde")] + pub toggle: HashSet, + #[serde(with = "event_set_serde")] + pub expand: HashSet, + #[serde(with = "event_set_serde")] + pub collapse: HashSet, } #[derive(Serialize, Deserialize)] @@ -322,15 +107,7 @@ pub struct ReactivityControl { pub spin_duration: Duration, } -impl Default for ReactivityControl { - fn default() -> Self { - Self { - query_debounce_duration: Duration::from_millis(600), - resize_debounce_duration: Duration::from_millis(200), - spin_duration: Duration::from_millis(300), - } - } -} +pub static DEFAULT_CONFIG: &str = include_str!("../default.toml"); /// Note that the config struct and the `.toml` configuration file are /// managed separately because the current toml crate @@ -348,7 +125,7 @@ impl Default for ReactivityControl { /// The main challenge is that, for nested structs, /// it is not able to wrap every leaf field with Option<>. /// https://github.com/colin-kiegel/rust-derive-builder/issues/254 -#[derive(Default, Serialize, Deserialize)] +#[derive(Serialize, Deserialize)] pub struct Config { pub no_hint: bool, pub reactivity_control: ReactivityControl, diff --git a/src/config/content_style.rs b/src/config/content_style.rs deleted file mode 100644 index 8edaed4..0000000 --- a/src/config/content_style.rs +++ /dev/null @@ -1,65 +0,0 @@ -use promkit_widgets::core::crossterm::style::{Attribute, Attributes, Color, ContentStyle}; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -struct ContentStyleDef { - foreground: Option, - background: Option, - underline: Option, - attributes: Option>, -} - -impl From<&ContentStyle> for ContentStyleDef { - fn from(style: &ContentStyle) -> Self { - ContentStyleDef { - foreground: style.foreground_color, - background: style.background_color, - underline: style.underline_color, - attributes: if style.attributes.is_empty() { - None - } else { - Some( - Attribute::iterator() - .filter(|x| style.attributes.has(*x)) - .collect(), - ) - }, - } - } -} - -impl From for ContentStyle { - fn from(style_def: ContentStyleDef) -> Self { - let mut style = ContentStyle::new(); - style.foreground_color = style_def.foreground; - style.background_color = style_def.background; - style.underline_color = style_def.underline; - if let Some(attributes) = style_def.attributes { - style.attributes = attributes - .into_iter() - .fold(Attributes::default(), |acc, x| acc | x); - } - style - } -} - -pub mod content_style_serde { - use super::*; - use serde::{Deserializer, Serializer}; - - pub fn serialize(style: &ContentStyle, serializer: S) -> Result - where - S: Serializer, - { - let style_def = ContentStyleDef::from(style); - style_def.serialize(serializer) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let style_def = ContentStyleDef::deserialize(deserializer)?; - Ok(ContentStyle::from(style_def)) - } -} diff --git a/src/config/event.rs b/src/config/event.rs deleted file mode 100644 index 7ef8c13..0000000 --- a/src/config/event.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::collections::HashSet; - -use promkit_widgets::core::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, -}; -use serde::{Deserialize, Serialize}; - -pub trait Matcher { - fn matches(&self, other: &T) -> bool; -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct EventDefSet(HashSet); - -impl Matcher for EventDefSet { - fn matches(&self, other: &Event) -> bool { - self.0.iter().any(|event_def| event_def.matches(other)) - } -} - -impl FromIterator for EventDefSet { - fn from_iter>(iter: I) -> Self { - EventDefSet(iter.into_iter().collect()) - } -} - -impl From for EventDefSet { - fn from(key_event_def: KeyEventDef) -> Self { - EventDefSet(HashSet::from_iter([EventDef::Key(key_event_def)])) - } -} - -impl From for EventDefSet { - fn from(mouse_event_def: MouseEventDef) -> Self { - EventDefSet(HashSet::from_iter([EventDef::Mouse(mouse_event_def)])) - } -} - -/// A part of `crossterm::event::Event`. -/// It is used for parsing from a config file or -/// for comparison with crossterm::event::Event. -/// https://docs.rs/crossterm/0.28.1/crossterm/event/enum.Event.html -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum EventDef { - Key(KeyEventDef), - Mouse(MouseEventDef), -} - -impl Matcher for EventDef { - fn matches(&self, other: &Event) -> bool { - match (self, other) { - (EventDef::Key(key_def), Event::Key(key_event)) => key_def.matches(key_event), - (EventDef::Mouse(mouse_def), Event::Mouse(mouse_event)) => { - mouse_def.matches(mouse_event) - } - _ => false, - } - } -} - -/// A part of `crossterm::event::KeyEvent`. -/// It is used for parsing from a config file or -/// for comparison with crossterm::event::KeyEvent. -/// https://docs.rs/crossterm/0.28.1/crossterm/event/struct.KeyEvent.html -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct KeyEventDef { - code: KeyCode, - modifiers: KeyModifiers, -} - -impl KeyEventDef { - pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { - KeyEventDef { code, modifiers } - } -} - -impl Matcher for KeyEventDef { - fn matches(&self, other: &KeyEvent) -> bool { - self.code == other.code && self.modifiers == other.modifiers - } -} - -/// A part of `crossterm::event::MouseEvent`. -/// It is used for parsing from a config file or -/// for comparison with crossterm::event::MouseEvent. -/// https://docs.rs/crossterm/0.28.1/crossterm/event/struct.MouseEvent.html -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct MouseEventDef { - kind: MouseEventKind, - modifiers: KeyModifiers, -} - -impl MouseEventDef { - #[allow(dead_code)] - pub fn new(kind: MouseEventKind, modifiers: KeyModifiers) -> Self { - MouseEventDef { kind, modifiers } - } -} - -impl Matcher for MouseEventDef { - fn matches(&self, other: &MouseEvent) -> bool { - self.kind == other.kind && self.modifiers == other.modifiers - } -} diff --git a/src/config/text_editor.rs b/src/config/text_editor.rs deleted file mode 100644 index ff39cb0..0000000 --- a/src/config/text_editor.rs +++ /dev/null @@ -1,33 +0,0 @@ -use promkit_widgets::text_editor::Mode; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -pub mod text_editor_mode_serde { - use super::*; - - pub fn serialize(mode: &Mode, serializer: S) -> Result - where - S: Serializer, - { - let mode_str = match mode { - Mode::Insert => "Insert", - Mode::Overwrite => "Overwrite", - // Add other variants if they exist - }; - mode_str.serialize(serializer) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let mode_str = String::deserialize(deserializer)?; - match mode_str.as_str() { - "Insert" => Ok(Mode::Insert), - "Overwrite" => Ok(Mode::Overwrite), - // Add other variants if they exist - _ => Err(serde::de::Error::custom(format!( - "Unknown Mode variant: {mode_str}", - ))), - } - } -} diff --git a/src/editor.rs b/src/editor.rs index d422fea..7a39e60 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2,27 +2,22 @@ use std::{future::Future, pin::Pin}; use promkit_widgets::{ core::{ - crossterm::{ - event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - style::{Color, ContentStyle}, - }, - Pane, PaneFactory, + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + grapheme::StyledGraphemes, + Widget, }, - text::{self, Text}, + status::{self, Severity}, text_editor, }; -use crate::{ - config::{event::Matcher, EditorKeybinds, EditorTheme}, - search::IncrementalSearcher, -}; +use crate::{config::EditorKeybinds, search::IncrementalSearcher}; pub struct Editor { handler: Handler, state: text_editor::State, - focus_theme: EditorTheme, - defocus_theme: EditorTheme, - guide: text::State, + focus_config: text_editor::Config, + defocus_config: text_editor::Config, + guide: status::State, searcher: IncrementalSearcher, editor_keybinds: EditorKeybinds, } @@ -31,54 +26,48 @@ impl Editor { pub fn new( state: text_editor::State, searcher: IncrementalSearcher, - focus_theme: EditorTheme, - defocus_theme: EditorTheme, + focus_config: text_editor::Config, + defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, ) -> Self { Self { handler: BOXED_EDITOR_HANDLER, state, - focus_theme, - defocus_theme, - guide: text::State::default(), + focus_config, + defocus_config, + guide: status::State::default(), searcher, editor_keybinds, } } pub fn focus(&mut self) { - self.state.prefix = self.focus_theme.prefix.clone(); - self.state.prefix_style = self.focus_theme.prefix_style; - self.state.inactive_char_style = self.focus_theme.inactive_char_style; - self.state.active_char_style = self.focus_theme.active_char_style; + self.state.config = self.focus_config.clone(); } pub fn defocus(&mut self) { - self.state.prefix = self.defocus_theme.prefix.clone(); - self.state.prefix_style = self.defocus_theme.prefix_style; - self.state.inactive_char_style = self.defocus_theme.inactive_char_style; - self.state.active_char_style = self.defocus_theme.active_char_style; + self.state.config = self.defocus_config.clone(); self.searcher.leave_search(); self.handler = BOXED_EDITOR_HANDLER; - self.guide.text = Default::default(); + self.guide = status::State::default(); } pub fn text(&self) -> String { self.state.texteditor.text_without_cursor().to_string() } - pub fn create_editor_pane(&self, width: u16, height: u16) -> Pane { - self.state.create_pane(width, height) + pub fn create_editor_pane(&self, width: u16, height: u16) -> StyledGraphemes { + self.state.create_graphemes(width, height) } - pub fn create_searcher_pane(&self, width: u16, height: u16) -> Pane { + pub fn create_searcher_pane(&self, width: u16, height: u16) -> StyledGraphemes { self.searcher.create_pane(width, height) } - pub fn create_guide_pane(&self, width: u16, height: u16) -> Pane { - self.guide.create_pane(width, height) + pub fn create_guide_pane(&self, width: u16, height: u16) -> StyledGraphemes { + self.guide.create_graphemes(width, height) } pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { @@ -101,88 +90,87 @@ const BOXED_SEARCHER_HANDLER: Handler = }; pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { - editor.guide.text = Default::default(); + editor.guide = status::State::default(); match event { - key if editor.editor_keybinds.completion.matches(key) => { + key if editor.editor_keybinds.completion.contains(key) => { let prefix = editor.state.texteditor.text_without_cursor().to_string(); match editor.searcher.start_search(&prefix) { Ok(result) => match result.head_item { Some(head) => { if result.load_state.loaded { - editor.guide.text = Text::from(format!( - "Loaded all ({}) suggestions", - result.load_state.loaded_item_len - )); - editor.guide.style = ContentStyle { - foreground_color: Some(Color::Green), - ..Default::default() - }; + editor.guide = status::State::new( + format!( + "Loaded all ({}) suggestions", + result.load_state.loaded_item_len + ), + Severity::Success, + ); } else { - editor.guide.text = Text::from(format!( - "Loaded partially ({}) suggestions", - result.load_state.loaded_item_len - )); - editor.guide.style = ContentStyle { - foreground_color: Some(Color::Green), - ..Default::default() - }; + editor.guide = status::State::new( + format!( + "Loaded partially ({}) suggestions", + result.load_state.loaded_item_len + ), + Severity::Success, + ); } editor.state.texteditor.replace(&head); editor.handler = BOXED_SEARCHER_HANDLER; } None => { - editor.guide.text = - Text::from(format!("No suggestion found for '{prefix}'")); - editor.guide.style = ContentStyle { - foreground_color: Some(Color::Yellow), - ..Default::default() - }; + editor.guide = status::State::new( + format!("No suggestion found for '{prefix}'"), + Severity::Warning, + ); } }, Err(e) => { - editor.guide.text = Text::from(format!("Failed to lookup suggestions: {e}")); - editor.guide.style = ContentStyle { - foreground_color: Some(Color::Yellow), - ..Default::default() - }; + editor.guide = status::State::new( + format!("Failed to lookup suggestions: {e}"), + Severity::Warning, + ); } } } // Move cursor. - key if editor.editor_keybinds.backward.matches(key) => { + key if editor.editor_keybinds.backward.contains(key) => { editor.state.texteditor.backward(); } - key if editor.editor_keybinds.forward.matches(key) => { + key if editor.editor_keybinds.forward.contains(key) => { editor.state.texteditor.forward(); } - key if editor.editor_keybinds.move_to_head.matches(key) => { + key if editor.editor_keybinds.move_to_head.contains(key) => { editor.state.texteditor.move_to_head(); } - key if editor.editor_keybinds.move_to_tail.matches(key) => { + key if editor.editor_keybinds.move_to_tail.contains(key) => { editor.state.texteditor.move_to_tail(); } // Move cursor to the nearest character. - key if editor.editor_keybinds.move_to_previous_nearest.matches(key) => { + key if editor + .editor_keybinds + .move_to_previous_nearest + .contains(key) => + { editor .state .texteditor - .move_to_previous_nearest(&editor.state.word_break_chars); + .move_to_previous_nearest(&editor.state.config.word_break_chars); } - key if editor.editor_keybinds.move_to_next_nearest.matches(key) => { + key if editor.editor_keybinds.move_to_next_nearest.contains(key) => { editor .state .texteditor - .move_to_next_nearest(&editor.state.word_break_chars); + .move_to_next_nearest(&editor.state.config.word_break_chars); } // Erase char(s). - key if editor.editor_keybinds.erase.matches(key) => { + key if editor.editor_keybinds.erase.contains(key) => { editor.state.texteditor.erase(); } - key if editor.editor_keybinds.erase_all.matches(key) => { + key if editor.editor_keybinds.erase_all.contains(key) => { editor.state.texteditor.erase_all(); } @@ -190,18 +178,18 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul key if editor .editor_keybinds .erase_to_previous_nearest - .matches(key) => + .contains(key) => { editor .state .texteditor - .erase_to_previous_nearest(&editor.state.word_break_chars); + .erase_to_previous_nearest(&editor.state.config.word_break_chars); } - key if editor.editor_keybinds.erase_to_next_nearest.matches(key) => { + key if editor.editor_keybinds.erase_to_next_nearest.contains(key) => { editor .state .texteditor - .erase_to_next_nearest(&editor.state.word_break_chars); + .erase_to_next_nearest(&editor.state.config.word_break_chars); } // Input char. @@ -216,7 +204,7 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: KeyEventState::NONE, - }) => match editor.state.edit_mode { + }) => match editor.state.config.edit_mode { text_editor::Mode::Insert => editor.state.texteditor.insert(*ch), text_editor::Mode::Overwrite => editor.state.texteditor.overwrite(*ch), }, @@ -228,7 +216,7 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul pub async fn search<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { match event { - key if editor.editor_keybinds.on_completion.down.matches(key) => { + key if editor.editor_keybinds.on_completion.down.contains(key) => { editor.searcher.down_with_load(); editor .state @@ -236,7 +224,7 @@ pub async fn search<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Res .replace(&editor.searcher.get_current_item()); } - key if editor.editor_keybinds.on_completion.up.matches(key) => { + key if editor.editor_keybinds.on_completion.up.contains(key) => { editor.searcher.up(); editor .state diff --git a/src/json.rs b/src/json.rs index fd8adac..348b0d7 100644 --- a/src/json.rs +++ b/src/json.rs @@ -5,21 +5,14 @@ use jaq_core::{ use jaq_json::Val; use promkit_widgets::{ - core::{ - crossterm::{ - event::Event, - style::{Attribute, Attributes, Color, ContentStyle}, - }, - pane::Pane, - PaneFactory, - }, - jsonstream::{self, format::RowFormatter, jsonz, JsonStream}, + core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, + jsonstream::{self, config::Config as JsonStreamConfig, jsonz, JsonStream}, serde_json::{self, Deserializer, Value}, - text::{self, Text}, + status::{self, Severity}, }; use crate::{ - config::{event::Matcher, JsonViewerKeybinds}, + config::JsonViewerKeybinds, processor::{ViewProvider, Visualizer}, search::SearchProvider, }; @@ -33,7 +26,7 @@ pub struct Json { impl Json { pub fn new( - formatter: RowFormatter, + formatter: JsonStreamConfig, input_stream: &'static [serde_json::Value], keybinds: JsonViewerKeybinds, ) -> anyhow::Result { @@ -41,8 +34,7 @@ impl Json { json: input_stream, state: jsonstream::State { stream: JsonStream::new(input_stream.iter()), - formatter, - lines: Default::default(), + config: formatter, }, keybinds, }) @@ -51,35 +43,35 @@ impl Json { fn operate(&mut self, event: &Event) { match event { // Move up. - event if self.keybinds.up.matches(event) => { + event if self.keybinds.up.contains(event) => { self.state.stream.up(); } // Move down. - event if self.keybinds.down.matches(event) => { + event if self.keybinds.down.contains(event) => { self.state.stream.down(); } // Move to head - event if self.keybinds.move_to_head.matches(event) => { + event if self.keybinds.move_to_head.contains(event) => { self.state.stream.head(); } // Move to tail - event if self.keybinds.move_to_tail.matches(event) => { + event if self.keybinds.move_to_tail.contains(event) => { self.state.stream.tail(); } // Toggle collapse/expand - event if self.keybinds.toggle.matches(event) => { + event if self.keybinds.toggle.contains(event) => { self.state.stream.toggle(); } - event if self.keybinds.expand.matches(event) => { + event if self.keybinds.expand.contains(event) => { self.state.stream.set_nodes_visibility(false); } - event if self.keybinds.collapse.matches(event) => { + event if self.keybinds.collapse.contains(event) => { self.state.stream.set_nodes_visibility(true); } @@ -91,59 +83,55 @@ impl Json { #[async_trait::async_trait] impl Visualizer for Json { async fn content_to_copy(&self) -> String { - self.state - .formatter - .format_raw_json(self.state.stream.rows()) + self.state.config.format_raw_json(self.state.stream.rows()) } - async fn create_init_pane(&mut self, area: (u16, u16)) -> Pane { - self.state.create_pane(area.0, area.1) + async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes { + self.state.create_graphemes(area.0, area.1) } - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> Pane { + async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { self.operate(event); - self.state.create_pane(area.0, area.1) + self.state.create_graphemes(area.0, area.1) } async fn create_panes_from_query( &mut self, area: (u16, u16), input: String, - ) -> (Option, Option) { + ) -> (Option, Option) { match run_jaq(&input, self.json) { Ok(ret) => { let mut guide = None; if ret.iter().all(|val| *val == Value::Null) { - guide = Some(text::State { - text: Text::from(format!("jq returned 'null', which may indicate a typo or incorrect filter: `{input}`")), - style: ContentStyle { - foreground_color: Some(Color::Yellow), - attributes: Attributes::from(Attribute::Bold), - ..Default::default() - }, - ..Default::default() - }.create_pane(area.0, area.1)); + guide = Some( + status::State::new( + format!( + "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" + ), + Severity::Warning, + ) + .create_graphemes(area.0, area.1), + ); + + self.state.stream = JsonStream::new(self.json.iter()); + } else { + self.state.stream = JsonStream::new(ret.iter()); } - self.state.stream = JsonStream::new(ret.iter()); - - (guide, Some(self.state.create_pane(area.0, area.1))) + (guide, Some(self.state.create_graphemes(area.0, area.1))) + } + Err(e) => { + self.state.stream = JsonStream::new(self.json.iter()); + + ( + Some( + status::State::new(format!("jq failed: `{e}`"), Severity::Error) + .create_graphemes(area.0, area.1), + ), + Some(self.state.create_graphemes(area.0, area.1)), + ) } - Err(e) => ( - Some( - text::State { - text: Text::from(format!("jq failed: `{e}`")), - style: ContentStyle { - foreground_color: Some(Color::Red), - attributes: Attributes::from(Attribute::Bold), - ..Default::default() - }, - ..Default::default() - } - .create_pane(area.0, area.1), - ), - None, - ), } } } @@ -186,12 +174,12 @@ fn run_jaq( #[derive(Clone)] pub struct JsonStreamProvider { - formatter: RowFormatter, + formatter: JsonStreamConfig, max_streams: Option, } impl JsonStreamProvider { - pub fn new(formatter: RowFormatter, max_streams: Option) -> Self { + pub fn new(formatter: JsonStreamConfig, max_streams: Option) -> Self { Self { formatter, max_streams, diff --git a/src/main.rs b/src/main.rs index 7c7ffb2..38e2ee7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,6 @@ use anyhow::anyhow; use clap::Parser; use config::Config; use promkit_widgets::{ - core::crossterm::style::Attribute, - jsonstream::format::RowFormatter, listbox::{self, Listbox}, text_editor::{self, TextEditor}, }; @@ -19,16 +17,17 @@ use editor::Editor; mod config; mod json; use json::JsonStreamProvider; +mod stdout_redirect; +use stdout_redirect::StdoutRedirect; mod processor; use processor::{ - init::ViewInitializer, monitor::ContextMonitor, spinner::SpinnerSpawner, Context, Processor, - ViewProvider, Visualizer, + init::ViewInitializer, monitor::ContextMonitor, Context, Processor, ViewProvider, Visualizer, }; mod prompt; mod search; use search::{IncrementalSearcher, SearchProvider}; -static DEFAULT_CONFIG: &str = include_str!("../default.toml"); +use crate::config::DEFAULT_CONFIG; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -72,6 +71,12 @@ pub struct Args { " )] default_filter: Option, + + #[arg( + long = "write-to-stdout", + help = "Write the current JSON result to stdout when exiting" + )] + write_to_stdout: bool, } /// Parses the input based on the provided arguments. @@ -148,22 +153,19 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); let input = parse_input(&args)?; - let mut config = Config::default(); - if let Ok(config_file) = determine_config_file(args.config_file) { - // Note that the configuration file absolutely exists. - let content = std::fs::read_to_string(&config_file) - // TODO: output the message as the initial guide pane. - .map_err(|e| anyhow!("Failed to read configuration file: {e}"))?; - config = Config::load_from(&content) - .map_err(|e| anyhow!("Failed to deserialize configuration file: {e}"))?; - } + let config = determine_config_file(args.config_file) + .and_then(|config_file| { + std::fs::read_to_string(&config_file) + .map_err(|e| anyhow!("Failed to read configuration file: {e}")) + }) + .and_then(|content| Config::load_from(&content)) + .unwrap_or_else(|_e| { + Config::load_from(DEFAULT_CONFIG).expect("Failed to load default configuration") + }); let listbox_state = listbox::State { listbox: Listbox::default(), - cursor: config.completion.cursor, - active_item_style: Some(config.completion.active_item_style), - inactive_item_style: Some(config.completion.inactive_item_style), - lines: config.completion.lines, + config: config.completion.listbox.clone(), }; let searcher = @@ -176,31 +178,11 @@ async fn main() -> anyhow::Result<()> { Default::default() }, history: Default::default(), - prefix: config.editor.theme_on_focus.prefix.clone(), - mask: Default::default(), - prefix_style: config.editor.theme_on_focus.prefix_style, - active_char_style: config.editor.theme_on_focus.active_char_style, - inactive_char_style: config.editor.theme_on_focus.inactive_char_style, - edit_mode: config.editor.mode, - word_break_chars: config.editor.word_break_chars, - lines: Default::default(), + config: config.editor.on_focus.clone(), }; - let provider = &mut JsonStreamProvider::new( - RowFormatter { - curly_brackets_style: config.json.theme.curly_brackets_style, - square_brackets_style: config.json.theme.square_brackets_style, - key_style: config.json.theme.key_style, - string_value_style: config.json.theme.string_value_style, - number_value_style: config.json.theme.number_value_style, - boolean_value_style: config.json.theme.boolean_value_style, - null_value_style: config.json.theme.null_value_style, - active_item_attribute: Attribute::Bold, - inactive_item_attribute: Attribute::Dim, - indent: config.json.theme.indent, - }, - config.json.max_streams, - ); + let provider = + &mut JsonStreamProvider::new(config.json.stream.clone(), config.json.max_streams); let item = Box::leak(input.into_boxed_str()); @@ -213,14 +195,16 @@ async fn main() -> anyhow::Result<()> { let editor = Editor::new( text_editor_state, searcher, - config.editor.theme_on_focus, - config.editor.theme_on_defocus, + config.editor.on_focus, + config.editor.on_defocus, // TODO: remove clones config.keybinds.on_editor.clone(), ); + let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; + // TODO: put all logics here. - prompt::run( + let maybe_output = prompt::run( item, config.reactivity_control, provider, @@ -228,8 +212,20 @@ async fn main() -> anyhow::Result<()> { loading_suggestions_task, config.no_hint, config.keybinds, + args.write_to_stdout, ) - .await?; + .await; + + stdout_redirect.restore()?; + let maybe_output = maybe_output?; + + if let Some(output) = maybe_output { + let mut stdout = io::stdout(); + stdout.write_all(output.as_bytes())?; + if !output.ends_with('\n') { + stdout.write_all(b"\n")?; + } + } Ok(()) } diff --git a/src/processor.rs b/src/processor.rs index e70db38..3e588e0 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -2,9 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use promkit_widgets::core::{ - crossterm::event::Event, - pane::{Pane, EMPTY_PANE}, - render::SharedRenderer, + crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, }; use tokio::{sync::Mutex, task::JoinHandle}; @@ -13,7 +11,10 @@ pub use init::ViewProvider; use crate::prompt::Index; pub mod monitor; -pub mod spinner; + +fn empty_pane() -> StyledGraphemes { + StyledGraphemes::default() +} #[derive(PartialEq)] enum State { @@ -25,13 +26,13 @@ enum State { #[async_trait] pub trait Visualizer: Send + Sync + 'static { async fn content_to_copy(&self) -> String; - async fn create_init_pane(&mut self, area: (u16, u16)) -> Pane; - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> Pane; + async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes; + async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes; async fn create_panes_from_query( &mut self, area: (u16, u16), query: String, - ) -> (Option, Option); + ) -> (Option, Option); } pub struct Context { @@ -90,11 +91,8 @@ impl Processor { // TODO: error handling let _ = shared_renderer .update([ - (Index::Guide, maybe_guide.unwrap_or(EMPTY_PANE.to_owned())), - ( - Index::Processor, - maybe_resp.unwrap_or(EMPTY_PANE.to_owned()), - ), + (Index::Guide, maybe_guide.unwrap_or_else(empty_pane)), + (Index::Processor, maybe_resp.unwrap_or_else(empty_pane)), ]) .render() .await; diff --git a/src/processor/monitor.rs b/src/processor/monitor.rs index b72a68a..7775d5f 100644 --- a/src/processor/monitor.rs +++ b/src/processor/monitor.rs @@ -1,5 +1,6 @@ -use std::sync::Arc; +use std::{future::Future, sync::Arc}; +use promkit_widgets::spinner; use tokio::sync::Mutex; use super::{Context, State}; @@ -12,9 +13,14 @@ impl ContextMonitor { pub fn new(shared: Arc>) -> Self { Self { shared } } +} - pub async fn is_idle(&self) -> bool { - let context = self.shared.lock().await; - context.state == State::Idle +impl spinner::State for ContextMonitor { + fn is_idle(&self) -> impl Future + Send { + let shared = self.shared.clone(); + async move { + let context = shared.lock().await; + context.state == State::Idle + } } } diff --git a/src/processor/spinner.rs b/src/processor/spinner.rs deleted file mode 100644 index b0e36d5..0000000 --- a/src/processor/spinner.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::sync::Arc; - -use promkit_widgets::core::{grapheme::StyledGraphemes, render::SharedRenderer, Pane}; -use tokio::{sync::Mutex, task::JoinHandle, time::Duration}; - -use crate::prompt::Index; - -use super::{Context, State}; - -const LOADING_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -pub struct SpinnerSpawner { - shared: Arc>, -} - -impl SpinnerSpawner { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } - - pub fn spawn_spin_task( - &self, - shared_renderer: SharedRenderer, - spin_duration: Duration, - ) -> JoinHandle<()> { - let shared = self.shared.clone(); - let mut frame_index = 0; - tokio::spawn(async move { - let mut interval = tokio::time::interval(spin_duration); - loop { - interval.tick().await; - - { - let shared_state = shared.lock().await; - if shared_state.state == State::Idle { - continue; - } - } - - frame_index = (frame_index + 1) % LOADING_FRAMES.len(); - - let pane = Pane::new(vec![StyledGraphemes::from(LOADING_FRAMES[frame_index])], 0); - { - // TODO: error handling - let _ = shared_renderer - .update([(Index::Processor, pane)]) - .render() - .await; - } - } - }) - } -} diff --git a/src/prompt.rs b/src/prompt.rs index c69df3c..52afd75 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -7,17 +7,18 @@ use promkit_widgets::{ crossterm::{ cursor, event::{ - Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, + DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, + MouseEventKind, }, execute, - style::{Color, ContentStyle}, terminal::{self, disable_raw_mode, enable_raw_mode}, }, - pane::EMPTY_PANE, + grapheme::StyledGraphemes, render::{Renderer, SharedRenderer}, - PaneFactory, + Widget, }, - text::{self, Text}, + spinner::{self, Spinner, State}, + status::{self, Severity}, }; use tokio::{ sync::{mpsc, Mutex, RwLock}, @@ -25,9 +26,9 @@ use tokio::{ }; use crate::{ - config::{event::Matcher, Keybinds, ReactivityControl}, - Context, ContextMonitor, Editor, Processor, SearchProvider, SpinnerSpawner, ViewInitializer, - ViewProvider, Visualizer, + config::{Keybinds, ReactivityControl}, + Context, ContextMonitor, Editor, Processor, SearchProvider, ViewInitializer, ViewProvider, + Visualizer, }; fn spawn_debouncer( @@ -57,46 +58,31 @@ fn spawn_debouncer( }) } -fn copy_to_clipboard(content: &str) -> text::State { +fn copy_to_clipboard(content: &str) -> status::State { match Clipboard::new() { Ok(mut clipboard) => match clipboard.set_text(content) { - Ok(_) => text::State { - text: Text::from("Copied to clipboard"), - style: ContentStyle { - foreground_color: Some(Color::Green), - ..Default::default() - }, - ..Default::default() - }, - Err(e) => text::State { - text: Text::from(format!("Failed to copy to clipboard: {e}")), - style: ContentStyle { - foreground_color: Some(Color::Red), - ..Default::default() - }, - ..Default::default() - }, + Ok(_) => status::State::new("Copied to clipboard", Severity::Success), + Err(e) => { + status::State::new(format!("Failed to copy to clipboard: {e}"), Severity::Error) + } }, // arboard fails (in the specific environment like linux?) on Clipboard::new() // suppress the errors (but still show them) not to break the prompt // https://github.com/1Password/arboard/issues/153 - Err(e) => text::State { - text: Text::from(format!("Failed to setup clipboard: {e}")), - style: ContentStyle { - foreground_color: Some(Color::Red), - ..Default::default() - }, - ..Default::default() - }, + Err(e) => status::State::new(format!("Failed to setup clipboard: {e}"), Severity::Error), } } +fn empty_pane() -> StyledGraphemes { + StyledGraphemes::default() +} + enum Focus { Editor, Processor, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { Editor = 0, Guide = 1, @@ -113,19 +99,20 @@ pub async fn run( loading_suggestions_task: JoinHandle>, no_hint: bool, keybinds: Keybinds, -) -> anyhow::Result<()> { + write_to_stdout: bool, +) -> anyhow::Result> { enable_raw_mode()?; execute!(io::stdout(), cursor::Hide)?; let size = terminal::size()?; let shared_renderer = SharedRenderer::new( - Renderer::try_new_with_panes( + Renderer::try_new_with_graphemes( [ (Index::Editor, editor.create_editor_pane(size.0, size.1)), - (Index::Guide, EMPTY_PANE.to_owned()), - (Index::Search, EMPTY_PANE.to_owned()), - (Index::Processor, EMPTY_PANE.to_owned()), + (Index::Guide, empty_pane()), + (Index::Search, empty_pane()), + (Index::Processor, empty_pane()), ] .into_iter(), true, @@ -154,9 +141,15 @@ pub async fn run( reactivity_control.resize_debounce_duration, ); - let spinner_spawner = SpinnerSpawner::new(ctx.clone()); - let spinning = - spinner_spawner.spawn_spin_task(shared_renderer.clone(), reactivity_control.spin_duration); + let spinning = tokio::spawn({ + let shared_renderer = shared_renderer.clone(); + let state = ContextMonitor::new(ctx.clone()); + let spin_duration = reactivity_control.spin_duration; + async move { + let spinner = Spinner::default().duration(spin_duration); + let _ = spinner::run(&spinner, state, Index::Processor, shared_renderer).await; + } + }); let mut focus = Focus::Editor; let (editor_event_tx, mut editor_event_rx) = mpsc::channel::(1); @@ -187,27 +180,36 @@ pub async fn run( 'main: loop { tokio::select! { Some(Ok(event)) = stream.next() => { + // Note: `HashSet::contains` compares full mouse events (including `column`/`row`), + // so wheel events are normalized to `(0, 0)` to match configured `ScrollUp`/`ScrollDown` bindings. + let event = match event { + Event::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + Event::Mouse(MouseEvent { + kind: mouse.kind, + column: 0, + row: 0, + modifiers: mouse.modifiers, + }) + } + other => other, + }; + match event { Event::Resize(width, height) => { debounce_resize_tx.send((width, height)).await?; }, - event if keybinds.exit.matches(&event) => { + event if keybinds.exit.contains(&event) => { break 'main }, - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { + event if keybinds.copy_query.contains(&event) => { editor_copy_tx.send(()).await?; }, - Event::Key(KeyEvent { - code: KeyCode::Char('o'), - modifiers: KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { + event if keybinds.copy_result.contains(&event) => { if context_monitor.is_idle().await { processor_copy_tx.send(()).await?; } else if !no_hint{ @@ -215,53 +217,48 @@ pub async fn run( shared_renderer.update([ ( Index::Guide, - text::State { - text: Text::from("Failed to copy while rendering is in progress.".to_string()), - style: ContentStyle { - foreground_color: Some(Color::Yellow), - ..Default::default() - }, - ..Default::default() - }.create_pane(size.0, size.1), + status::State::new( + "Failed to copy while rendering is in progress.", + Severity::Warning, + ) + .create_graphemes(size.0, size.1), ), ]).render().await?; } }, - Event::Key(KeyEvent { - code: KeyCode::Down, - modifiers: KeyModifiers::SHIFT, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) | Event::Key(KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::SHIFT, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => { + event if keybinds.switch_mode.contains(&event) => { match focus { Focus::Editor => { if context_monitor.is_idle().await { focus = Focus::Processor; editor_focus_tx.send(false).await?; + execute!( + io::stdout(), + terminal::EnterAlternateScreen, + EnableMouseCapture, + )?; } else if !no_hint{ let size = terminal::size()?; shared_renderer.update([ ( Index::Guide, - text::State { - text: Text::from("Failed to switch pane while rendering is in progress.".to_string()), - style: ContentStyle { - foreground_color: Some(Color::Yellow), - ..Default::default() - }, - ..Default::default() - }.create_pane(size.0, size.1)), + status::State::new( + "Failed to switch pane while rendering is in progress.", + Severity::Warning, + ) + .create_graphemes(size.0, size.1), + ), ]).render().await?; } }, Focus::Processor => { focus = Focus::Editor; editor_focus_tx.send(true).await?; + execute!( + io::stdout(), + terminal::LeaveAlternateScreen, + DisableMouseCapture, + )?; }, } }, @@ -307,7 +304,7 @@ pub async fn run( }; shared_renderer.update([ (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { EMPTY_PANE.to_owned() }), + (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), ]).render().await?; } Some(()) = editor_copy_rx.recv() => { @@ -318,7 +315,7 @@ pub async fn run( let guide = copy_to_clipboard(&text); if !no_hint { let size = terminal::size()?; - let pane = guide.create_pane(size.0, size.1); + let pane = guide.create_graphemes(size.0, size.1); shared_renderer.update([ (Index::Guide, pane), ]).render().await?; @@ -346,7 +343,7 @@ pub async fn run( { shared_renderer.update([ (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { EMPTY_PANE.to_owned() }), + (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), (Index::Search, searcher_pane), ]).render().await?; } @@ -360,11 +357,11 @@ pub async fn run( }) }; + let shared_visualizer = Arc::new(Mutex::new(initializing.await?)); let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let visualizer = initializing.await?; - let shared_visualizer = Arc::new(Mutex::new(visualizer)); + let shared_visualizer = shared_visualizer.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -373,7 +370,7 @@ pub async fn run( let guide = copy_to_clipboard(&visualizer.content_to_copy().await); if !no_hint { let size = terminal::size()?; - let pane = guide.create_pane(size.0, size.1); + let pane = guide.create_graphemes(size.0, size.1); shared_renderer.update([ (Index::Guide, pane), ]).render().await?; @@ -409,7 +406,7 @@ pub async fn run( { shared_renderer.update([ (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { EMPTY_PANE.to_owned() }), + (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), (Index::Search, searcher_pane), ]).render().await?; } @@ -435,6 +432,13 @@ pub async fn run( main_task.await??; + let output = if write_to_stdout { + let visualizer = shared_visualizer.lock().await; + Some(visualizer.content_to_copy().await) + } else { + None + }; + loading_suggestions_task.abort(); spinning.abort(); query_debouncer.abort(); @@ -442,8 +446,8 @@ pub async fn run( editor_task.abort(); processor_task.abort(); - execute!(io::stdout(), cursor::Show)?; + execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; disable_raw_mode()?; - Ok(()) + Ok(output) } diff --git a/src/search.rs b/src/search.rs index 75004f0..b46869f 100644 --- a/src/search.rs +++ b/src/search.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeSet, sync::Arc}; use anyhow::anyhow; use async_trait::async_trait; use promkit_widgets::{ - core::{pane::Pane, PaneFactory}, + core::{grapheme::StyledGraphemes, Widget}, listbox::{self, Listbox}, }; use tokio::{ @@ -101,7 +101,7 @@ impl IncrementalSearcher { .listbox .len() .saturating_sub(self.state.listbox.position()) - < self.state.lines.unwrap_or(1) + < self.state.config.lines.unwrap_or(1) { self.load_more(); } @@ -111,12 +111,12 @@ impl IncrementalSearcher { self.state.listbox.get().to_string() } - pub fn create_pane(&self, width: u16, height: u16) -> Pane { - self.state.create_pane(width, height) + pub fn create_pane(&self, width: u16, height: u16) -> StyledGraphemes { + self.state.create_graphemes(width, height) } pub fn leave_search(&mut self) { - self.state.listbox = Listbox::from_displayable(Vec::::new()); + self.state.listbox = Listbox::from(Vec::::new()); self.search_chunk_remaining = Vec::::new(); } @@ -141,7 +141,7 @@ impl IncrementalSearcher { .drain(..self.search_result_chunk_size.min(items.len())) .collect::>(); self.search_chunk_remaining = items; - self.state.listbox = Listbox::from_displayable(used); + self.state.listbox = Listbox::from(used); Ok(StartSearchResult { head_item: Some(self.state.listbox.get().to_string()), load_state: state.clone(), diff --git a/src/stdout_redirect.rs b/src/stdout_redirect.rs new file mode 100644 index 0000000..dc0e67d --- /dev/null +++ b/src/stdout_redirect.rs @@ -0,0 +1,89 @@ +use std::{ + fs::File, + io::{self, IsTerminal}, +}; + +use anyhow::anyhow; + +#[cfg(unix)] +use rustix::{ + io::dup, + stdio::{dup2_stdout, stdout}, +}; +#[cfg(unix)] +use std::os::fd::OwnedFd; + +/// Redirects `stdout` to the controlling TTY while the TUI is running. +/// +/// This is needed when `--write-to-stdout` is used with piped stdout, e.g. +/// `cat data.json | jnv --write-to-stdout | pbcopy`. +/// During interactive rendering, cursor controls and screen output must go to +/// a terminal, not to the downstream pipe. +/// +/// Unix flow: +/// 1. Save current `fd=1` with `dup` (`saved_stdout`). +/// 2. Replace `fd=1` with `/dev/tty` via `dup2_stdout` for TUI rendering. +/// 3. Restore the original `fd=1` on exit. +/// +/// After restore, writing to `io::stdout()` again goes to the original pipe +/// (for example `pbcopy`) so the final JSON can be emitted there. +/// +/// Note: +/// `stdout` is file descriptor 1 (FD 1), not the screen itself. +/// Its destination is chosen by the shell when the process starts: +/// terminal (`cmd`), file (`cmd > out.txt`), or pipe (`cmd | next`). +/// Therefore, we cannot just write to `stdout` for TUI rendering when it's piped. +/// Instead, we must write directly to the terminal device (`/dev/tty` on Unix). +pub struct StdoutRedirect { + #[cfg(unix)] + saved_stdout: Option, +} + +impl StdoutRedirect { + pub fn try_new_for_tui(write_to_stdout: bool) -> anyhow::Result { + if !write_to_stdout || io::stdout().is_terminal() { + return Ok(Self { + #[cfg(unix)] + saved_stdout: None, + }); + } + + #[cfg(unix)] + { + let tty = File::options() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|e| anyhow!("Failed to open /dev/tty for TUI rendering: {e}"))?; + + let saved_fd = dup(stdout()).map_err(|e| anyhow!("Failed to duplicate stdout: {e}"))?; + dup2_stdout(&tty).map_err(|e| anyhow!("Failed to redirect stdout to /dev/tty: {e}"))?; + + Ok(Self { + saved_stdout: Some(saved_fd), + }) + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "`--write-to-stdout` with piped stdout is not supported on this platform" + )) + } + } + + pub fn restore(&mut self) -> anyhow::Result<()> { + #[cfg(unix)] + if let Some(saved_stdout) = self.saved_stdout.take() { + dup2_stdout(&saved_stdout).map_err(|e| anyhow!("Failed to restore stdout: {e}"))?; + } + + Ok(()) + } +} + +impl Drop for StdoutRedirect { + fn drop(&mut self) { + let _ = self.restore(); + } +}