From f3544ea387857387a29a79c8e8dd4d11a562779b Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 20:39:30 -0500 Subject: [PATCH 01/11] Add a web wrapper around vortex-tui Signed-off-by: Nicholas Gates --- Cargo.lock | 545 +++++--------------------- vortex-tui/.gitignore | 1 + vortex-tui/Cargo.toml | 77 +++- vortex-tui/Makefile | 11 + vortex-tui/README.md | 26 ++ vortex-tui/src/browse/app.rs | 64 ++- vortex-tui/src/browse/mod.rs | 557 ++++++++++++++------------- vortex-tui/src/browse/ui/layouts.rs | 37 +- vortex-tui/src/browse/ui/mod.rs | 35 +- vortex-tui/src/browse/ui/query.rs | 73 ++-- vortex-tui/src/browse/ui/segments.rs | 14 +- vortex-tui/src/lib.rs | 204 +++++----- vortex-tui/src/main.rs | 10 +- vortex-tui/web/index.html | 111 ++++++ vortex-tui/web/style.css | 91 +++++ 15 files changed, 941 insertions(+), 915 deletions(-) create mode 100644 vortex-tui/.gitignore create mode 100644 vortex-tui/Makefile create mode 100644 vortex-tui/web/index.html create mode 100644 vortex-tui/web/style.css diff --git a/Cargo.lock b/Cargo.lock index f330414d9c1..34824eaabae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -136,7 +136,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -416,7 +416,7 @@ version = "57.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" dependencies = [ - "bitflags 2.11.0", + "bitflags", "serde_core", "serde_json", ] @@ -645,15 +645,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -672,6 +663,32 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "beamterm-data" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf74a7c20e83a40e542c032c918f1b5653ad4a137b49b7063e61030983ce243" +dependencies = [ + "compact_str", + "miniz_oxide", +] + +[[package]] +name = "beamterm-renderer" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c27cc93582c7ce0f7350adcd2e0ffe21237687ba6bfb67cdea15e6e37001d19f" +dependencies = [ + "beamterm-data", + "compact_str", + "console_error_panic_hook", + "js-sys", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "better_io" version = "0.2.0" @@ -698,7 +715,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cexpr", "clang-sys", "itertools 0.13.0", @@ -712,33 +729,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -1103,7 +1099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf 0.12.1", + "phf", ] [[package]] @@ -1218,7 +1214,7 @@ dependencies = [ "getrandom 0.2.17", "glob", "libc", - "nix 0.30.1", + "nix", "serde", "serde_json", "statrs", @@ -1426,6 +1422,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-random" version = "0.1.18" @@ -1611,7 +1617,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crossterm_winapi", "derive_more", "document-features", @@ -1648,16 +1654,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf 0.11.3", -] - [[package]] name = "csv" version = "1.4.0" @@ -3208,12 +3204,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "deranged" version = "0.5.8" @@ -3286,7 +3276,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3478,7 +3468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3493,15 +3483,6 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -3552,16 +3533,6 @@ dependencies = [ "ext-trait", ] -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fast-float2" version = "0.2.3" @@ -3593,17 +3564,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" version = "0.2.27" @@ -3621,12 +3581,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - [[package]] name = "fixed-hash" version = "0.8.0" @@ -3640,12 +3594,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -3658,7 +3606,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.0", + "bitflags", "rustc_version", ] @@ -3898,7 +3846,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -4710,7 +4658,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4791,7 +4739,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4905,12 +4853,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lance" version = "2.0.1" @@ -5581,7 +5523,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "redox_syscall 0.7.2", ] @@ -5604,7 +5546,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -5736,16 +5678,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", -] - [[package]] name = "macro_rules_attribute" version = "0.1.3" @@ -5818,12 +5750,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - [[package]] name = "memoffset" version = "0.9.1" @@ -5984,26 +5910,13 @@ version = "6.6.666" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf5a574dadd7941adeaa71823ecba5e28331b8313fb2e1c6a5c7e5981ea53ad6" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -6058,7 +5971,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d09e31153abd7996f22a50d70f43af6c2ebf96a44ee250326ed15d4e183744c9" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", "bstr", "indexmap", "noodles-bgzf", @@ -6133,7 +6046,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6162,17 +6075,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -6239,7 +6141,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -6332,7 +6234,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.11.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -6457,15 +6359,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-float" version = "5.1.0" @@ -6684,120 +6577,25 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "hashbrown 0.15.5", "indexmap", "serde", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared 0.12.1", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", + "phf_shared", ] [[package]] @@ -7492,8 +7290,6 @@ dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", "ratatui-widgets", ] @@ -7503,7 +7299,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.0", + "bitflags", "compact_str", "hashbrown 0.16.1", "indoc", @@ -7529,33 +7325,13 @@ dependencies = [ "ratatui-core", ] -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - [[package]] name = "ratatui-widgets" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.16.1", "indoc", "instability", @@ -7568,6 +7344,22 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "ratzilla" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc729362a3a8714b512f330c8dfaa03979813e9491e21b8e49b81e1e5d78014" +dependencies = [ + "beamterm-renderer", + "bitvec", + "compact_str", + "console_error_panic_hook", + "ratatui", + "thiserror 2.0.18", + "unicode-width 0.2.2", + "web-sys", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -7620,7 +7412,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -7629,7 +7421,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -7956,7 +7748,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7969,11 +7761,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8143,7 +7935,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8823,7 +8615,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -9063,10 +8855,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9088,75 +8880,12 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf 0.11.3", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset 0.4.2", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix 0.29.0", - "num-derive", - "num-traits", - "ordered-float 4.6.0", - "pest", - "pest_derive", - "phf 0.11.3", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "test-with" version = "0.14.11" @@ -9520,7 +9249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags", "bytes", "futures-core", "futures-util", @@ -9723,12 +9452,6 @@ dependencies = [ "typify-impl", ] -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "uint" version = "0.10.0" @@ -9855,7 +9578,6 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "atomic", "getrandom 0.4.1", "js-sys", "serde_core", @@ -10642,7 +10364,7 @@ dependencies = [ "arrow-array", "arrow-schema", "async-trait", - "bit-vec 0.8.0", + "bit-vec", "futures", "itertools 0.14.0", "parking_lot", @@ -10748,6 +10470,7 @@ dependencies = [ "arrow-array", "arrow-schema", "clap", + "console_error_panic_hook", "crossterm", "datafusion 52.1.0", "env_logger", @@ -10757,14 +10480,19 @@ dependencies = [ "humansize", "indicatif", "itertools 0.14.0", + "js-sys", "parquet", "ratatui", + "ratzilla", "serde", "serde_json", "taffy", "tokio", "vortex", "vortex-datafusion", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -10806,15 +10534,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -10958,7 +10677,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -10993,78 +10712,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float 4.6.0", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "which" version = "8.0.0" @@ -11098,7 +10745,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11570,7 +11217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", diff --git a/vortex-tui/.gitignore b/vortex-tui/.gitignore new file mode 100644 index 00000000000..beeb42ab25f --- /dev/null +++ b/vortex-tui/.gitignore @@ -0,0 +1 @@ +web/pkg/ diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index a6ef5f61bbc..9348acd886e 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -13,32 +13,77 @@ repository = { workspace = true } rust-version = { workspace = true } version = { workspace = true } +[features] +default = ["native"] +native = [ + "dep:arrow-array", + "dep:arrow-schema", + "dep:clap", + "dep:crossterm", + "dep:datafusion", + "dep:env_logger", + "dep:indicatif", + "dep:parquet", + "dep:tokio", + "dep:vortex-datafusion", + "ratatui/crossterm", + "vortex/tokio", + "vortex/zstd", +] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "vx" +path = "src/main.rs" +required-features = ["native"] + [dependencies] +# Shared dependencies anyhow = { workspace = true } -arrow-array = { workspace = true } -arrow-schema = { workspace = true } -clap = { workspace = true, features = ["derive"] } -crossterm = { workspace = true } -datafusion = { workspace = true } -env_logger = { version = "0.11" } flatbuffers = { workspace = true } futures = { workspace = true, features = ["executor"] } fuzzy-matcher = { workspace = true } humansize = { workspace = true } -indicatif = { workspace = true, features = ["futures"] } itertools = { workspace = true } -parquet = { workspace = true, features = ["arrow", "async"] } -ratatui = { workspace = true } +ratatui = { version = "0.30", default-features = false } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } taffy = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } -vortex = { workspace = true, features = ["tokio"] } -vortex-datafusion = { workspace = true } +vortex = { version = "0.1.0", path = "../vortex", default-features = false, features = ["files"] } + +# Native-only dependencies (gated behind "native" feature) +arrow-array = { workspace = true, optional = true } +arrow-schema = { workspace = true, optional = true } +clap = { workspace = true, features = ["derive"], optional = true } +crossterm = { workspace = true, optional = true } +datafusion = { workspace = true, optional = true } +env_logger = { version = "0.11", optional = true } +indicatif = { workspace = true, features = ["futures"], optional = true } +parquet = { workspace = true, features = ["arrow", "async"], optional = true } +tokio = { workspace = true, features = ["rt-multi-thread"], optional = true } +vortex-datafusion = { workspace = true, optional = true } + +# WASM-only dependencies +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" +js-sys = "0.3" +ratzilla = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = { workspace = true } +web-sys = { version = "0.3", features = [ + "console", + "DataTransfer", + "Document", + "DragEvent", + "Element", + "File", + "FileReader", + "HtmlElement", + "HtmlInputElement", + "Window", +] } [lints] workspace = true - -[[bin]] -name = "vx" -path = "src/main.rs" diff --git a/vortex-tui/Makefile b/vortex-tui/Makefile new file mode 100644 index 00000000000..3fb7d729212 --- /dev/null +++ b/vortex-tui/Makefile @@ -0,0 +1,11 @@ +.PHONY: serve clean-web + +web/pkg: src/**/*.rs Cargo.toml + wasm-pack build --target web --no-default-features --out-dir web/pkg + +serve: web/pkg + @echo "Open http://localhost:8000" + cd web && python3 -m http.server + +clean-web: + rm -rf web/pkg diff --git a/vortex-tui/README.md b/vortex-tui/README.md index 6a8576565c7..4a220c63fe8 100644 --- a/vortex-tui/README.md +++ b/vortex-tui/README.md @@ -107,6 +107,32 @@ vx convert input.parquet --out output.vortex vx convert input.parquet --out output.vortex --compress ``` +## Browser (WASM) + +The TUI browser can also run in a web browser via WebAssembly. Users can drag-and-drop a `.vtx` +file onto a web page and interactively explore it using the same Layout and Segments tabs. + +### Quick start + +```bash +# From the vortex-tui directory: +make serve +``` + +This builds the WASM package and starts a local server at `http://localhost:8000`. + +Requires `wasm-pack` (`cargo install wasm-pack`). + +### How it works + +- **Native** builds use `crossterm` for terminal I/O and `CurrentThreadRuntime` (smol-based) for + async execution. The `native` feature (enabled by default) pulls in DataFusion, crossterm, and + other native-only dependencies. +- **WASM** builds use [ratzilla](https://github.com/ratatui/ratzilla) (the official ratatui web + backend) for rendering and `WasmRuntime` for async execution. Building with + `--no-default-features` excludes all native-only dependencies. +- The Query tab (DataFusion SQL) is only available in native builds. + ## Development TODO: diff --git a/vortex-tui/src/browse/app.rs b/vortex-tui/src/browse/app.rs index b892c529ab4..6fa9701a30f 100644 --- a/vortex-tui/src/browse/app.rs +++ b/vortex-tui/src/browse/app.rs @@ -3,7 +3,6 @@ //! Application state and data structures for the TUI browser. -use std::path::Path; use std::sync::Arc; use futures::executor::block_on; @@ -14,7 +13,6 @@ use vortex::dtype::DType; use vortex::error::VortexExpect; use vortex::error::VortexResult; use vortex::file::Footer; -use vortex::file::OpenOptionsSessionExt; use vortex::file::SegmentSpec; use vortex::file::VortexFile; use vortex::layout::LayoutRef; @@ -25,7 +23,6 @@ use vortex::layout::segments::SegmentId; use vortex::layout::segments::SegmentSource; use vortex::session::VortexSession; -use super::ui::QueryState; use super::ui::SegmentGridState; /// The currently active tab in the TUI browser. @@ -44,6 +41,7 @@ pub enum Tab { Segments, /// SQL query interface powered by DataFusion. + #[cfg(not(target_arch = "wasm32"))] Query, } @@ -222,9 +220,9 @@ pub enum KeyMode { /// /// The state is preserved when switching between tabs, allowing users to return to their previous /// position. -pub struct AppState<'a> { +pub struct AppState { /// The Vortex session used to read array data during rendering. - pub session: &'a VortexSession, + pub session: VortexSession, /// The current input mode (normal navigation or search). pub key_mode: KeyMode, @@ -251,7 +249,7 @@ pub struct AppState<'a> { pub layouts_list_state: ListState, /// State for the segment grid display. - pub segment_grid_state: SegmentGridState<'a>, + pub segment_grid_state: SegmentGridState, /// The size of the last rendered frame. pub frame_size: Size, @@ -259,23 +257,29 @@ pub struct AppState<'a> { /// Vertical scroll offset for the encoding tree display in flat layout view. pub tree_scroll_offset: u16, - /// State for the Query tab - pub query_state: QueryState, + /// State for the Query tab. + #[cfg(not(target_arch = "wasm32"))] + pub query_state: super::ui::QueryState, - /// File path for use in query execution + /// File path for use in query execution. + #[cfg(not(target_arch = "wasm32"))] pub file_path: String, } -impl<'a> AppState<'a> { - /// Create a new application state by opening a Vortex file. +impl AppState { + /// Create a new application state by opening a Vortex file from a path. /// /// # Errors /// /// Returns an error if the file cannot be opened or read. + #[cfg(not(target_arch = "wasm32"))] pub async fn new( - session: &'a VortexSession, - path: impl AsRef, - ) -> VortexResult> { + session: &VortexSession, + path: impl AsRef, + ) -> VortexResult { + use vortex::file::OpenOptionsSessionExt; + + let session = session.clone(); let vxf = session.open_options().open_path(path.as_ref()).await?; let cursor = LayoutCursor::new(vxf.footer().clone(), vxf.segment_source()); @@ -298,11 +302,41 @@ impl<'a> AppState<'a> { segment_grid_state: SegmentGridState::default(), frame_size: Size::new(0, 0), tree_scroll_offset: 0, - query_state: QueryState::default(), + query_state: super::ui::QueryState::default(), file_path, }) } + /// Create a new application state from an in-memory buffer. + /// + /// # Errors + /// + /// Returns an error if the buffer does not contain a valid Vortex file. + #[cfg(target_arch = "wasm32")] + pub fn from_buffer( + session: VortexSession, + buffer: vortex::buffer::ByteBuffer, + ) -> VortexResult { + use vortex::file::OpenOptionsSessionExt; + + let vxf = session.open_options().open_buffer(buffer)?; + let cursor = LayoutCursor::new(vxf.footer().clone(), vxf.segment_source()); + + Ok(AppState { + session, + vxf, + cursor, + key_mode: KeyMode::default(), + search_filter: String::new(), + filter: None, + current_tab: Tab::default(), + layouts_list_state: ListState::default().with_selected(Some(0)), + segment_grid_state: SegmentGridState::default(), + frame_size: Size::new(0, 0), + tree_scroll_offset: 0, + }) + } + /// Clear the current search filter and return to showing all children. pub fn clear_search(&mut self) { self.search_filter.clear(); diff --git a/vortex-tui/src/browse/mod.rs b/vortex-tui/src/browse/mod.rs index adeb0bb4da0..bf186ac1264 100644 --- a/vortex-tui/src/browse/mod.rs +++ b/vortex-tui/src/browse/mod.rs @@ -3,26 +3,16 @@ //! Interactive TUI browser for Vortex files. -use std::path::Path; - use app::AppState; use app::KeyMode; use app::Tab; -use crossterm::event; -use crossterm::event::Event; -use crossterm::event::KeyCode; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use ratatui::DefaultTerminal; -use ui::QueryFocus; -use ui::SortDirection; -use ui::render_app; +use input::InputEvent; +use input::InputKeyCode; use vortex::error::VortexExpect; -use vortex::error::VortexResult; use vortex::layout::layouts::flat::FlatVTable; -use vortex::session::VortexSession; pub mod app; +pub(crate) mod input; pub mod ui; /// Scroll amount for single-line navigation (up/down arrows). @@ -38,27 +28,7 @@ const SEGMENT_SCROLL_HORIZONTAL_STEP: usize = 20; /// Scroll amount for segment grid horizontal jump (Home/End). const SEGMENT_SCROLL_HORIZONTAL_JUMP: usize = 200; -// Use the VortexResult and potentially launch a Backtrace. -async fn run(mut terminal: DefaultTerminal, mut app: AppState<'_>) -> VortexResult<()> { - loop { - terminal.draw(|frame| render_app(&mut app, frame))?; - - let event = event::read()?; - let event_result = match app.key_mode { - KeyMode::Normal => handle_normal_mode(&mut app, event), - KeyMode::Search => handle_search_mode(&mut app, event), - }; - - match event_result { - HandleResult::Exit => { - return Ok(()); - } - HandleResult::Continue => { /* continue execution */ } - } - } -} - -enum HandleResult { +pub(crate) enum HandleResult { Continue, Exit, } @@ -83,306 +53,361 @@ fn navigate_layout_down(app: &mut AppState, amount: usize) { } } +/// Handle a key event in normal input mode. +/// +/// Returns [`HandleResult::Exit`] if the user pressed the quit key. #[allow(clippy::cognitive_complexity)] -fn handle_normal_mode(app: &mut AppState, event: Event) -> HandleResult { - if let Event::Key(key) = event - && key.kind == KeyEventKind::Press +pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> HandleResult { + // Check if we're in Query tab with SQL input focus - handle text input first + #[cfg(not(target_arch = "wasm32"))] { - // Check if we're in Query tab with SQL input focus - handle text input first + use ui::QueryFocus; + use ui::SortDirection; + let in_sql_input = app.current_tab == Tab::Query && app.query_state.focus == QueryFocus::SqlInput; - // Handle SQL input mode - most keys should type into the input if in_sql_input { - match (key.code, key.modifiers) { - // These keys exit/switch even in SQL input mode - (KeyCode::Tab, _) => { + match (&event.code, event.ctrl, event.alt, event.shift) { + (InputKeyCode::Tab, ..) => { app.current_tab = Tab::Layout; } - (KeyCode::Esc, _) => { + (InputKeyCode::Esc, ..) => { app.query_state.toggle_focus(); } - (KeyCode::Enter, _) => { - // Execute the SQL query with COUNT(*) for pagination + (InputKeyCode::Enter, ..) => { app.query_state.sort_column = None; app.query_state.sort_direction = SortDirection::None; let file_path = app.file_path.clone(); app.query_state - .execute_initial_query(app.session, &file_path); - // Switch focus to results table after executing + .execute_initial_query(&app.session, &file_path); app.query_state.focus = QueryFocus::ResultsTable; } - // Navigation keys - (KeyCode::Left, _) => app.query_state.move_cursor_left(), - (KeyCode::Right, _) => app.query_state.move_cursor_right(), - (KeyCode::Home, _) => app.query_state.move_cursor_start(), - (KeyCode::End, _) => app.query_state.move_cursor_end(), - // Control key shortcuts - (KeyCode::Char('a'), KeyModifiers::CONTROL) => app.query_state.move_cursor_start(), - (KeyCode::Char('e'), KeyModifiers::CONTROL) => app.query_state.move_cursor_end(), - (KeyCode::Char('u'), KeyModifiers::CONTROL) => app.query_state.clear_input(), - (KeyCode::Char('b'), KeyModifiers::CONTROL) => app.query_state.move_cursor_left(), - (KeyCode::Char('f'), KeyModifiers::CONTROL) => app.query_state.move_cursor_right(), - (KeyCode::Char('d'), KeyModifiers::CONTROL) => { - app.query_state.delete_char_forward() - } - // Delete keys - (KeyCode::Backspace, _) => app.query_state.delete_char(), - (KeyCode::Delete, _) => app.query_state.delete_char_forward(), - // All other characters get typed into the input - (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => { - app.query_state.insert_char(c); + (InputKeyCode::Left, ..) => app.query_state.move_cursor_left(), + (InputKeyCode::Right, ..) => app.query_state.move_cursor_right(), + (InputKeyCode::Home, ..) => app.query_state.move_cursor_start(), + (InputKeyCode::End, ..) => app.query_state.move_cursor_end(), + (InputKeyCode::Char('a'), true, ..) => app.query_state.move_cursor_start(), + (InputKeyCode::Char('e'), true, ..) => app.query_state.move_cursor_end(), + (InputKeyCode::Char('u'), true, ..) => app.query_state.clear_input(), + (InputKeyCode::Char('b'), true, ..) => app.query_state.move_cursor_left(), + (InputKeyCode::Char('f'), true, ..) => app.query_state.move_cursor_right(), + (InputKeyCode::Char('d'), true, ..) => app.query_state.delete_char_forward(), + (InputKeyCode::Backspace, ..) => app.query_state.delete_char(), + (InputKeyCode::Delete, ..) => app.query_state.delete_char_forward(), + (InputKeyCode::Char(c), false, false, _) => { + app.query_state.insert_char(*c); } _ => {} } return HandleResult::Continue; } + } - // Normal mode handling for all other cases - match (key.code, key.modifiers) { - (KeyCode::Char('q'), _) => { - return HandleResult::Exit; + match (&event.code, event.ctrl, event.alt, event.shift) { + (InputKeyCode::Char('q'), ..) => { + return HandleResult::Exit; + } + (InputKeyCode::Tab, ..) => { + app.current_tab = match app.current_tab { + Tab::Layout => Tab::Segments, + #[cfg(not(target_arch = "wasm32"))] + Tab::Segments => Tab::Query, + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => Tab::Layout, + #[cfg(target_arch = "wasm32")] + Tab::Segments => Tab::Layout, + }; + } + + #[cfg(not(target_arch = "wasm32"))] + (InputKeyCode::Char('['), false, false, _) => { + if app.current_tab == Tab::Query { + app.query_state + .prev_page(&app.session, &app.file_path.clone()); } - (KeyCode::Tab, _) => { - app.current_tab = match app.current_tab { - Tab::Layout => Tab::Segments, - Tab::Segments => Tab::Query, - Tab::Query => Tab::Layout, - }; + } + + #[cfg(not(target_arch = "wasm32"))] + (InputKeyCode::Char(']'), false, false, _) => { + if app.current_tab == Tab::Query { + app.query_state + .next_page(&app.session, &app.file_path.clone()); } + } - // Query tab: '[' for previous page - (KeyCode::Char('['), KeyModifiers::NONE) => { - if app.current_tab == Tab::Query { + (InputKeyCode::Up, ..) + | (InputKeyCode::Char('k'), false, false, _) + | (InputKeyCode::Char('p'), true, ..) => match app.current_tab { + Tab::Layout => navigate_layout_up(app, SCROLL_LINE), + Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_LINE), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + app.query_state.table_state.select_previous(); + } + }, + (InputKeyCode::Down, ..) + | (InputKeyCode::Char('j'), false, false, _) + | (InputKeyCode::Char('n'), true, ..) => match app.current_tab { + Tab::Layout => navigate_layout_down(app, SCROLL_LINE), + Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_LINE), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + app.query_state.table_state.select_next(); + } + }, + (InputKeyCode::PageUp, ..) | (InputKeyCode::Char('v'), _, true, _) => { + match app.current_tab { + Tab::Layout => navigate_layout_up(app, SCROLL_PAGE), + Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { app.query_state - .prev_page(app.session, &app.file_path.clone()); + .prev_page(&app.session, &app.file_path.clone()); } } - - // Query tab: ']' for next page - (KeyCode::Char(']'), KeyModifiers::NONE) => { - if app.current_tab == Tab::Query { + } + (InputKeyCode::PageDown, ..) | (InputKeyCode::Char('v'), true, ..) => { + match app.current_tab { + Tab::Layout => navigate_layout_down(app, SCROLL_PAGE), + Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { app.query_state - .next_page(app.session, &app.file_path.clone()); + .next_page(&app.session, &app.file_path.clone()); } } - - (KeyCode::Up | KeyCode::Char('k'), _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - match app.current_tab { - Tab::Layout => navigate_layout_up(app, SCROLL_LINE), - Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_LINE), - Tab::Query => { - app.query_state.table_state.select_previous(); - } - } + } + (InputKeyCode::Home, ..) | (InputKeyCode::Char('<'), _, true, _) => match app.current_tab { + Tab::Layout => app.layouts_list_state.select_first(), + Tab::Segments => app + .segment_grid_state + .scroll_left(SEGMENT_SCROLL_HORIZONTAL_JUMP), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + app.query_state.table_state.select_first(); } - (KeyCode::Down | KeyCode::Char('j'), _) - | (KeyCode::Char('n'), KeyModifiers::CONTROL) => match app.current_tab { - Tab::Layout => navigate_layout_down(app, SCROLL_LINE), - Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_LINE), - Tab::Query => { - app.query_state.table_state.select_next(); - } - }, - (KeyCode::PageUp, _) | (KeyCode::Char('v'), KeyModifiers::ALT) => { - match app.current_tab { - Tab::Layout => navigate_layout_up(app, SCROLL_PAGE), - Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE), - Tab::Query => { - app.query_state - .prev_page(app.session, &app.file_path.clone()); - } - } + }, + (InputKeyCode::End, ..) | (InputKeyCode::Char('>'), _, true, _) => match app.current_tab { + Tab::Layout => app.layouts_list_state.select_last(), + Tab::Segments => app + .segment_grid_state + .scroll_right(SEGMENT_SCROLL_HORIZONTAL_JUMP), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + app.query_state.table_state.select_last(); } - (KeyCode::PageDown, _) | (KeyCode::Char('v'), KeyModifiers::CONTROL) => { - match app.current_tab { - Tab::Layout => navigate_layout_down(app, SCROLL_PAGE), - Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE), - Tab::Query => { - app.query_state - .next_page(app.session, &app.file_path.clone()); - } - } + }, + (InputKeyCode::Enter, ..) => { + if app.current_tab == Tab::Layout && app.cursor.layout().nchildren() > 0 { + let selected = app.layouts_list_state.selected().unwrap_or_default(); + app.cursor = app.cursor.child(selected); + app.reset_layout_view_state(); } - (KeyCode::Home, _) | (KeyCode::Char('<'), KeyModifiers::ALT) => match app.current_tab { - Tab::Layout => app.layouts_list_state.select_first(), - Tab::Segments => app - .segment_grid_state - .scroll_left(SEGMENT_SCROLL_HORIZONTAL_JUMP), - Tab::Query => { - app.query_state.table_state.select_first(); - } - }, - (KeyCode::End, _) | (KeyCode::Char('>'), KeyModifiers::ALT) => match app.current_tab { - Tab::Layout => app.layouts_list_state.select_last(), - Tab::Segments => app - .segment_grid_state - .scroll_right(SEGMENT_SCROLL_HORIZONTAL_JUMP), - Tab::Query => { - app.query_state.table_state.select_last(); - } - }, - (KeyCode::Enter, _) => { - if app.current_tab == Tab::Layout && app.cursor.layout().nchildren() > 0 { - // Descend into the layout subtree for the selected child. - let selected = app.layouts_list_state.selected().unwrap_or_default(); - app.cursor = app.cursor.child(selected); - app.reset_layout_view_state(); - } + } + (InputKeyCode::Left, ..) + | (InputKeyCode::Char('h'), false, false, _) + | (InputKeyCode::Char('b'), true, ..) => match app.current_tab { + Tab::Layout => { + app.cursor = app.cursor.parent(); + app.reset_layout_view_state(); } - (KeyCode::Left | KeyCode::Char('h'), _) - | (KeyCode::Char('b'), KeyModifiers::CONTROL) => match app.current_tab { - Tab::Layout => { - app.cursor = app.cursor.parent(); - app.reset_layout_view_state(); - } - Tab::Segments => app - .segment_grid_state - .scroll_left(SEGMENT_SCROLL_HORIZONTAL_STEP), - Tab::Query => { - app.query_state.horizontal_scroll = - app.query_state.horizontal_scroll.saturating_sub(1); - } - }, - (KeyCode::Right | KeyCode::Char('l'), _) | (KeyCode::Char('b'), KeyModifiers::ALT) => { - match app.current_tab { - Tab::Layout => {} - Tab::Segments => app - .segment_grid_state - .scroll_right(SEGMENT_SCROLL_HORIZONTAL_STEP), - Tab::Query => { - let max_col = app.query_state.column_count().saturating_sub(1); - if app.query_state.horizontal_scroll < max_col { - app.query_state.horizontal_scroll += 1; - } - } - } + Tab::Segments => app + .segment_grid_state + .scroll_left(SEGMENT_SCROLL_HORIZONTAL_STEP), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + app.query_state.horizontal_scroll = + app.query_state.horizontal_scroll.saturating_sub(1); } - - (KeyCode::Char('/'), _) | (KeyCode::Char('s'), KeyModifiers::CONTROL) => { - if app.current_tab != Tab::Query { - app.key_mode = KeyMode::Search; + }, + (InputKeyCode::Right, ..) + | (InputKeyCode::Char('l'), false, false, _) + | (InputKeyCode::Char('b'), _, true, _) => match app.current_tab { + Tab::Layout => {} + Tab::Segments => app + .segment_grid_state + .scroll_right(SEGMENT_SCROLL_HORIZONTAL_STEP), + #[cfg(not(target_arch = "wasm32"))] + Tab::Query => { + let max_col = app.query_state.column_count().saturating_sub(1); + if app.query_state.horizontal_scroll < max_col { + app.query_state.horizontal_scroll += 1; } } - - (KeyCode::Char('s'), KeyModifiers::NONE) => { - if app.current_tab == Tab::Query { - // Sort by selected column - modifies the SQL query - let col = app.query_state.selected_column(); - app.query_state.apply_sort(app.session, col, &app.file_path); - } + }, + + (InputKeyCode::Char('/'), ..) | (InputKeyCode::Char('s'), true, ..) => { + #[cfg(not(target_arch = "wasm32"))] + if app.current_tab == Tab::Query { + // Don't enter search mode from query tab + } else { + app.key_mode = KeyMode::Search; + } + #[cfg(target_arch = "wasm32")] + { + app.key_mode = KeyMode::Search; } + } - (KeyCode::Esc, _) => { - if app.current_tab == Tab::Query { - // Toggle focus in Query tab - app.query_state.toggle_focus(); - } + #[cfg(not(target_arch = "wasm32"))] + (InputKeyCode::Char('s'), false, false, _) => { + if app.current_tab == Tab::Query { + let col = app.query_state.selected_column(); + app.query_state + .apply_sort(&app.session, col, &app.file_path); } + } - _ => {} + #[cfg(not(target_arch = "wasm32"))] + (InputKeyCode::Esc, ..) => { + if app.current_tab == Tab::Query { + app.query_state.toggle_focus(); + } } + + _ => {} } HandleResult::Continue } -fn handle_search_mode(app: &mut AppState, event: Event) -> HandleResult { - if let Event::Key(key) = event { - match (key.code, key.modifiers) { - (KeyCode::Esc, _) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => { - app.key_mode = KeyMode::Normal; - app.clear_search(); - } +/// Handle a key event in search mode. +pub(crate) fn handle_search_mode(app: &mut AppState, event: InputEvent) -> HandleResult { + match (&event.code, event.ctrl, event.alt, event.shift) { + (InputKeyCode::Esc, ..) | (InputKeyCode::Char('g'), true, ..) => { + app.key_mode = KeyMode::Normal; + app.clear_search(); + } - (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - if app.current_tab == Tab::Layout { - navigate_layout_up(app, SCROLL_LINE); - } + (InputKeyCode::Up, ..) | (InputKeyCode::Char('p'), true, ..) => { + if app.current_tab == Tab::Layout { + navigate_layout_up(app, SCROLL_LINE); } - (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - if app.current_tab == Tab::Layout { - navigate_layout_down(app, SCROLL_LINE); - } + } + (InputKeyCode::Down, ..) | (InputKeyCode::Char('n'), true, ..) => { + if app.current_tab == Tab::Layout { + navigate_layout_down(app, SCROLL_LINE); } - (KeyCode::PageUp, _) | (KeyCode::Char('v'), KeyModifiers::ALT) => { - if app.current_tab == Tab::Layout { - navigate_layout_up(app, SCROLL_PAGE); - } + } + (InputKeyCode::PageUp, ..) | (InputKeyCode::Char('v'), _, true, _) => { + if app.current_tab == Tab::Layout { + navigate_layout_up(app, SCROLL_PAGE); } - (KeyCode::PageDown, _) | (KeyCode::Char('v'), KeyModifiers::CONTROL) => { - if app.current_tab == Tab::Layout { - navigate_layout_down(app, SCROLL_PAGE); - } + } + (InputKeyCode::PageDown, ..) | (InputKeyCode::Char('v'), true, ..) => { + if app.current_tab == Tab::Layout { + navigate_layout_down(app, SCROLL_PAGE); } - (KeyCode::Home, _) | (KeyCode::Char('<'), KeyModifiers::ALT) => { - if app.current_tab == Tab::Layout { - app.layouts_list_state.select_first(); - } + } + (InputKeyCode::Home, ..) | (InputKeyCode::Char('<'), _, true, _) => { + if app.current_tab == Tab::Layout { + app.layouts_list_state.select_first(); } - (KeyCode::End, _) | (KeyCode::Char('>'), KeyModifiers::ALT) => { - if app.current_tab == Tab::Layout { - app.layouts_list_state.select_last(); - } + } + (InputKeyCode::End, ..) | (InputKeyCode::Char('>'), _, true, _) => { + if app.current_tab == Tab::Layout { + app.layouts_list_state.select_last(); } + } - (KeyCode::Enter, _) => { - if app.current_tab == Tab::Layout - && app.cursor.layout().nchildren() > 0 - && let Some(selected) = app.layouts_list_state.selected() - { - app.cursor = match app.filter.as_ref() { - None => app.cursor.child(selected), - Some(filter) => { - let child_idx = filter - .iter() - .enumerate() - .filter_map(|(idx, show)| show.then_some(idx)) - .nth(selected) - .vortex_expect("There must be a selected item in the filter"); - - app.cursor.child(child_idx) - } - }; - - app.reset_layout_view_state(); - app.clear_search(); - app.key_mode = KeyMode::Normal; - } - } + (InputKeyCode::Enter, ..) => { + if app.current_tab == Tab::Layout + && app.cursor.layout().nchildren() > 0 + && let Some(selected) = app.layouts_list_state.selected() + { + app.cursor = match app.filter.as_ref() { + None => app.cursor.child(selected), + Some(filter) => { + let child_idx = filter + .iter() + .enumerate() + .filter_map(|(idx, show)| show.then_some(idx)) + .nth(selected) + .vortex_expect("There must be a selected item in the filter"); + + app.cursor.child(child_idx) + } + }; - (KeyCode::Backspace, _) | (KeyCode::Char('h'), KeyModifiers::CONTROL) => { - app.search_filter.pop(); + app.reset_layout_view_state(); + app.clear_search(); + app.key_mode = KeyMode::Normal; } + } - (KeyCode::Char(c), _) => { - app.layouts_list_state.select_first(); - app.search_filter.push(c); - } + (InputKeyCode::Backspace, ..) | (InputKeyCode::Char('h'), true, ..) => { + app.search_filter.pop(); + } - _ => {} + (InputKeyCode::Char(c), false, false, _) => { + app.layouts_list_state.select_first(); + app.search_filter.push(*c); } + + _ => {} } HandleResult::Continue } -// TODO: add tui_logger and have a logs tab so we can see the log output from -// doing Vortex things. +// --- Native-only crossterm event loop --- -/// Launch the interactive TUI browser for a Vortex file. -/// -/// # Errors -/// -/// Returns an error if the file cannot be opened or if there's a terminal I/O error. -pub async fn exec_tui(session: &VortexSession, file: impl AsRef) -> VortexResult<()> { - let app = AppState::new(session, file).await?; +#[cfg(not(target_arch = "wasm32"))] +mod native { + use crossterm::event; + use crossterm::event::Event; + use crossterm::event::KeyEventKind; + use ratatui::DefaultTerminal; + use vortex::error::VortexResult; + use vortex::session::VortexSession; + + use super::ui::render_app; + use super::*; - let mut terminal = ratatui::init(); - terminal.clear()?; + async fn run(mut terminal: DefaultTerminal, mut app: AppState) -> VortexResult<()> { + loop { + terminal.draw(|frame| render_app(&mut app, frame))?; - run(terminal, app).await?; + let raw_event = event::read()?; + if let Event::Key(key) = raw_event { + if key.kind != KeyEventKind::Press { + continue; + } - ratatui::restore(); - Ok(()) + let input = InputEvent::from(key); + let result = match app.key_mode { + KeyMode::Normal => handle_normal_mode(&mut app, input), + KeyMode::Search => handle_search_mode(&mut app, input), + }; + + if matches!(result, HandleResult::Exit) { + return Ok(()); + } + } + } + } + + /// Launch the interactive TUI browser for a Vortex file. + /// + /// # Errors + /// + /// Returns an error if the file cannot be opened or if there's a terminal I/O error. + pub async fn exec_tui( + session: &VortexSession, + file: impl AsRef, + ) -> VortexResult<()> { + let app = AppState::new(session, file).await?; + + let mut terminal = ratatui::init(); + terminal.clear()?; + + run(terminal, app).await?; + + ratatui::restore(); + Ok(()) + } } + +#[cfg(not(target_arch = "wasm32"))] +pub use native::exec_tui; diff --git a/vortex-tui/src/browse/ui/layouts.rs b/vortex-tui/src/browse/ui/layouts.rs index 7545f432e61..42d9c6e908a 100644 --- a/vortex-tui/src/browse/ui/layouts.rs +++ b/vortex-tui/src/browse/ui/layouts.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use futures::executor::block_on; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use humansize::DECIMAL; @@ -26,8 +27,6 @@ use ratatui::widgets::StatefulWidget; use ratatui::widgets::Table; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; -use tokio::runtime::Handle; -use tokio::task::block_in_place; use vortex::array::Array; use vortex::array::ArrayRef; use vortex::array::MaskFuture; @@ -41,7 +40,7 @@ use crate::browse::app::AppState; use crate::browse::app::LayoutCursor; /// Render the Layouts tab. -pub fn render_layouts(app_state: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { +pub fn render_layouts(app_state: &mut AppState, area: Rect, buf: &mut Buffer) { let [header_area, detail_area] = Layout::vertical([Constraint::Length(10), Constraint::Min(1)]).areas(area); @@ -114,28 +113,26 @@ fn render_layout_header(cursor: &LayoutCursor, area: Rect, buf: &mut Buffer) { } /// Render the inner Array for a FlatLayout. -fn render_array(app: &AppState<'_>, area: Rect, buf: &mut Buffer, is_stats_table: bool) { +fn render_array(app: &AppState, area: Rect, buf: &mut Buffer, is_stats_table: bool) { let row_count = app.cursor.layout().row_count(); let reader = app .cursor .layout() - .new_reader("".into(), app.vxf.segment_source(), app.session) + .new_reader("".into(), app.vxf.segment_source(), &app.session) .vortex_expect("Failed to create reader"); // FIXME(ngates): our TUI app should never perform I/O in the render loop... - let array = block_in_place(|| { - Handle::current().block_on( - reader - .projection_evaluation( - &(0..row_count), - &root(), - MaskFuture::new_true( - usize::try_from(row_count).vortex_expect("row_count overflowed usize"), - ), - ) - .vortex_expect("Failed to construct projection"), - ) - }) + let array = block_on( + reader + .projection_evaluation( + &(0..row_count), + &root(), + MaskFuture::new_true( + usize::try_from(row_count).vortex_expect("row_count overflowed usize"), + ), + ) + .vortex_expect("Failed to construct projection"), + ) .vortex_expect("Failed to read flat array"); // Show the metadata as JSON. (show count of encoded bytes as well) @@ -159,7 +156,7 @@ fn render_array(app: &AppState<'_>, area: Rect, buf: &mut Buffer, is_stats_table .chain(struct_array.names().iter().map(|x| x.as_ref())) .map(Cell::from) .collect::() - .style(Style::default().fg(Color::Green).bg(Color::DarkGray)) + .style(Style::default().fg(Color::Rgb(206, 229, 98)).bg(Color::DarkGray)) .height(1); assert_eq!(app.cursor.dtype(), array.dtype()); @@ -304,7 +301,7 @@ fn render_child_list_items( // Render the List view. StatefulWidget::render( - List::new(list_items).highlight_style(Style::default().black().on_white().bold()), + List::new(list_items).highlight_style(Style::default().fg(Color::Rgb(16, 16, 16)).bg(Color::Rgb(89, 113, 253)).bold()), inner_area, buf, &mut app.layouts_list_state, diff --git a/vortex-tui/src/browse/ui/mod.rs b/vortex-tui/src/browse/ui/mod.rs index 6080d3940c5..b9e8e4a7455 100644 --- a/vortex-tui/src/browse/ui/mod.rs +++ b/vortex-tui/src/browse/ui/mod.rs @@ -4,13 +4,18 @@ //! UI rendering components for the TUI browser. mod layouts; +#[cfg(not(target_arch = "wasm32"))] mod query; mod segments; use layouts::render_layouts; +#[cfg(not(target_arch = "wasm32"))] pub use query::QueryFocus; +#[cfg(not(target_arch = "wasm32"))] pub use query::QueryState; +#[cfg(not(target_arch = "wasm32"))] pub use query::SortDirection; +#[cfg(not(target_arch = "wasm32"))] use query::render_query; use ratatui::prelude::*; use ratatui::widgets::Block; @@ -30,7 +35,7 @@ use crate::browse::ui::segments::segments_ui; /// - The outer border with title and help text /// - The tab bar showing available views /// - The content area for the currently selected tab -pub fn render_app(app: &mut AppState<'_>, frame: &mut Frame<'_>) { +pub fn render_app(app: &mut AppState, frame: &mut Frame<'_>) { // Render the outer tab view, then render the inner frame view. let bottom_text = if app.key_mode == KeyMode::Search { Line::from(format!( @@ -47,7 +52,7 @@ pub fn render_app(app: &mut AppState<'_>, frame: &mut Frame<'_>) { let shell = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().magenta()) + .border_style(Style::default().fg(Color::Rgb(89, 113, 253))) .title_top("Vortex Browser") .title_bottom(bottom_text) .title_alignment(Alignment::Center); @@ -67,15 +72,28 @@ pub fn render_app(app: &mut AppState<'_>, frame: &mut Frame<'_>) { .areas(inner_area); // Display a tab indicator. - let selected_tab = match app.current_tab { - Tab::Layout => 0, - Tab::Segments => 1, - Tab::Query => 2, + #[cfg(not(target_arch = "wasm32"))] + let (selected_tab, tab_names) = { + let selected = match app.current_tab { + Tab::Layout => 0, + Tab::Segments => 1, + Tab::Query => 2, + }; + (selected, vec!["File Layout", "Segments", "Query"]) }; - let tabs = Tabs::new(["File Layout", "Segments", "Query"]) + #[cfg(target_arch = "wasm32")] + let (selected_tab, tab_names) = { + let selected = match app.current_tab { + Tab::Layout => 0, + Tab::Segments => 1, + }; + (selected, vec!["File Layout", "Segments"]) + }; + + let tabs = Tabs::new(tab_names) .style(Style::default().bold().white()) - .highlight_style(Style::default().bold().black().on_white()) + .highlight_style(Style::default().bold().fg(Color::Rgb(16, 16, 16)).bg(Color::Rgb(89, 113, 253))) .select(Some(selected_tab)); frame.render_widget(tabs, tab_view); @@ -86,6 +104,7 @@ pub fn render_app(app: &mut AppState<'_>, frame: &mut Frame<'_>) { render_layouts(app, app_view, frame.buffer_mut()); } Tab::Segments => segments_ui(app, app_view, frame.buffer_mut()), + #[cfg(not(target_arch = "wasm32"))] Tab::Query => render_query(app, app_view, frame.buffer_mut()), } } diff --git a/vortex-tui/src/browse/ui/query.rs b/vortex-tui/src/browse/ui/query.rs index 692edd74423..abd8846dd56 100644 --- a/vortex-tui/src/browse/ui/query.rs +++ b/vortex-tui/src/browse/ui/query.rs @@ -23,8 +23,7 @@ use ratatui::widgets::StatefulWidget; use ratatui::widgets::Table; use ratatui::widgets::TableState; use ratatui::widgets::Widget; -use tokio::runtime::Handle; -use tokio::task::block_in_place; +use tokio::runtime::Runtime; use crate::browse::app::AppState; use crate::datafusion_helper::arrow_value_to_json; @@ -403,24 +402,23 @@ pub fn execute_query( file_path: &str, sql: &str, ) -> Result { - block_in_place(|| { - Handle::current().block_on(async { - let batches = execute_vortex_query(session, file_path, sql).await?; + let rt = Runtime::new().map_err(|e| format!("Failed to create tokio runtime: {e}"))?; + rt.block_on(async { + let batches = execute_vortex_query(session, file_path, sql).await?; - let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); - let column_names = if let Some(batch) = batches.first() { - let schema = batch.schema(); - schema.fields().iter().map(|f| f.name().clone()).collect() - } else { - vec![] - }; + let column_names = if let Some(batch) = batches.first() { + let schema = batch.schema(); + schema.fields().iter().map(|f| f.name().clone()).collect() + } else { + vec![] + }; - Ok(QueryResults { - batches, - total_rows, - column_names, - }) + Ok(QueryResults { + batches, + total_rows, + column_names, }) }) } @@ -431,31 +429,30 @@ pub fn get_row_count( file_path: &str, base_query: &str, ) -> Result { - block_in_place(|| { - Handle::current().block_on(async { - let count_sql = format!("SELECT COUNT(*) as count FROM ({base_query}) AS subquery"); - - let batches = execute_vortex_query(session, file_path, &count_sql).await?; - - // Extract count from result - if let Some(batch) = batches.first() - && batch.num_rows() > 0 - && batch.num_columns() > 0 - { - use arrow_array::Int64Array; - if let Some(arr) = batch.column(0).as_any().downcast_ref::() { - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - return Ok(arr.value(0) as usize); - } + let rt = Runtime::new().map_err(|e| format!("Failed to create tokio runtime: {e}"))?; + rt.block_on(async { + let count_sql = format!("SELECT COUNT(*) as count FROM ({base_query}) AS subquery"); + + let batches = execute_vortex_query(session, file_path, &count_sql).await?; + + // Extract count from result + if let Some(batch) = batches.first() + && batch.num_rows() > 0 + && batch.num_columns() > 0 + { + use arrow_array::Int64Array; + if let Some(arr) = batch.column(0).as_any().downcast_ref::() { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + return Ok(arr.value(0) as usize); } + } - Ok(0) - }) + Ok(0) }) } /// Render the Query tab UI. -pub fn render_query(app: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { +pub fn render_query(app: &mut AppState, area: Rect, buf: &mut Buffer) { let [input_area, results_area] = Layout::vertical([Constraint::Length(5), Constraint::Min(10)]).areas(area); @@ -463,7 +460,7 @@ pub fn render_query(app: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { render_results_table(app, results_area, buf); } -fn render_sql_input(app: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { +fn render_sql_input(app: &mut AppState, area: Rect, buf: &mut Buffer) { let is_focused = app.query_state.focus == QueryFocus::SqlInput; let border_color = if is_focused { @@ -515,7 +512,7 @@ fn render_sql_input(app: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { paragraph.render(inner, buf); } -fn render_results_table(app: &mut AppState<'_>, area: Rect, buf: &mut Buffer) { +fn render_results_table(app: &mut AppState, area: Rect, buf: &mut Buffer) { let is_focused = app.query_state.focus == QueryFocus::ResultsTable; let border_color = if is_focused { diff --git a/vortex-tui/src/browse/ui/segments.rs b/vortex-tui/src/browse/ui/segments.rs index b8d20dbc2b1..7c11400a421 100644 --- a/vortex-tui/src/browse/ui/segments.rs +++ b/vortex-tui/src/browse/ui/segments.rs @@ -40,11 +40,11 @@ use crate::segment_tree::collect_segment_tree; /// This struct manages the layout tree and scroll state for displaying segments in a grid view. /// The segment tree is lazily computed on first render and cached for subsequent frames. #[derive(Debug, Clone, Default)] -pub struct SegmentGridState<'a> { +pub struct SegmentGridState { /// The computed layout tree for the segment grid, or `None` if not yet computed. /// /// Contains the taffy layout tree, root node ID, and a map of node contents. - pub segment_tree: Option<(TaffyTree<()>, NodeId, HashMap>)>, + pub segment_tree: Option<(TaffyTree<()>, NodeId, HashMap)>, /// State for the horizontal scrollbar widget. pub horizontal_scroll_state: ScrollbarState, @@ -65,7 +65,7 @@ pub struct SegmentGridState<'a> { pub max_vertical_scroll: usize, } -impl SegmentGridState<'_> { +impl SegmentGridState { /// Scroll the viewport up by the given amount. pub fn scroll_up(&mut self, amount: usize) { self.vertical_scroll = self.vertical_scroll.saturating_sub(amount); @@ -102,9 +102,9 @@ impl SegmentGridState<'_> { } #[derive(Debug, Clone)] -pub struct NodeContents<'a> { +pub struct NodeContents { title: FieldName, - contents: Vec>, + contents: Vec>, } #[expect( @@ -277,9 +277,9 @@ fn render_tree( Some(r) } -fn to_display_segment_tree<'a>( +fn to_display_segment_tree( mut segment_tree: SegmentTree, -) -> anyhow::Result<(TaffyTree<()>, NodeId, HashMap>)> { +) -> anyhow::Result<(TaffyTree<()>, NodeId, HashMap)> { // Extra node for the parent node of the segment specs, and one parent node as the root let mut tree = TaffyTree::with_capacity( segment_tree diff --git a/vortex-tui/src/lib.rs b/vortex-tui/src/lib.rs index 69c5b463e62..0f19db0f847 100644 --- a/vortex-tui/src/lib.rs +++ b/vortex-tui/src/lib.rs @@ -10,11 +10,13 @@ //! //! ```ignore //! use vortex::session::VortexSession; +//! use vortex::io::runtime::current::CurrentThreadRuntime; //! use vortex::io::session::RuntimeSessionExt; //! use vortex_tui::browse; //! -//! let session = VortexSession::default().with_tokio(); -//! browse::exec_tui(&session, "my_file.vortex").await?; +//! let runtime = CurrentThreadRuntime::new(); +//! let session = VortexSession::default().with_handle(runtime.handle()); +//! runtime.block_on(browse::exec_tui(&session, "my_file.vortex"))?; //! ``` #![deny(clippy::missing_errors_doc)] @@ -22,110 +24,128 @@ #![deny(clippy::missing_safety_doc)] #![deny(missing_docs)] -use std::ffi::OsString; -use std::path::PathBuf; - -use clap::CommandFactory; -use clap::Parser; -use vortex::error::VortexExpect; -use vortex::session::VortexSession; - pub mod browse; +pub mod segment_tree; + +#[cfg(not(target_arch = "wasm32"))] pub mod convert; +#[cfg(not(target_arch = "wasm32"))] pub mod datafusion_helper; +#[cfg(not(target_arch = "wasm32"))] pub mod inspect; +#[cfg(not(target_arch = "wasm32"))] pub mod query; -pub mod segment_tree; +#[cfg(not(target_arch = "wasm32"))] pub mod segments; +#[cfg(not(target_arch = "wasm32"))] pub mod tree; -#[derive(clap::Parser)] -#[command(version)] -struct Cli { - #[clap(subcommand)] - command: Commands, -} +#[cfg(target_arch = "wasm32")] +pub mod wasm; -#[derive(Debug, clap::Subcommand)] -enum Commands { - /// Print tree views of a Vortex file (layout tree or array tree) - Tree(tree::TreeArgs), - /// Convert a Parquet file to a Vortex file. Chunking occurs on Parquet RowGroup boundaries. - Convert(#[command(flatten)] convert::ConvertArgs), - /// Interactively browse the Vortex file. - Browse { file: PathBuf }, - /// Inspect Vortex file footer and metadata - Inspect(inspect::InspectArgs), - /// Execute a SQL query against a Vortex file using DataFusion - Query(query::QueryArgs), - /// Display segment information for a Vortex file - Segments(segments::SegmentsArgs), -} +#[cfg(not(target_arch = "wasm32"))] +mod native_cli { + use std::ffi::OsString; + use std::path::PathBuf; -impl Commands { - fn file_path(&self) -> &PathBuf { - match self { - Commands::Tree(args) => match &args.mode { - tree::TreeMode::Array { file, .. } => file, - tree::TreeMode::Layout { file, .. } => file, - }, - Commands::Browse { file } => file, - Commands::Convert(flags) => &flags.file, - Commands::Inspect(args) => &args.file, - Commands::Query(args) => &args.file, - Commands::Segments(args) => &args.file, - } + use clap::CommandFactory; + use clap::Parser; + use vortex::error::VortexExpect; + use vortex::session::VortexSession; + + #[derive(clap::Parser)] + #[command(version)] + struct Cli { + #[clap(subcommand)] + command: Commands, } -} -/// Main entrypoint for `vx` that launches a [`VortexSession`]. -/// -/// Parses arguments from [`std::env::args_os`]. See [`launch_from`] to supply explicit arguments. -/// -/// # Errors -/// -/// Raises any errors from subcommands. -pub async fn launch(session: &VortexSession) -> anyhow::Result<()> { - launch_from(session, std::env::args_os()).await -} + #[derive(Debug, clap::Subcommand)] + enum Commands { + /// Print tree views of a Vortex file (layout tree or array tree) + Tree(super::tree::TreeArgs), + /// Convert a Parquet file to a Vortex file. Chunking occurs on Parquet RowGroup boundaries. + Convert(#[command(flatten)] super::convert::ConvertArgs), + /// Interactively browse the Vortex file. + Browse { file: PathBuf }, + /// Inspect Vortex file footer and metadata + Inspect(super::inspect::InspectArgs), + /// Execute a SQL query against a Vortex file using DataFusion + Query(super::query::QueryArgs), + /// Display segment information for a Vortex file + Segments(super::segments::SegmentsArgs), + } -/// Launch `vx` with explicit command-line arguments. -/// -/// This is useful when embedding the TUI inside another process (e.g. Python) where -/// [`std::env::args`] may not reflect the intended arguments. -/// -/// # Errors -/// -/// Raises any errors from subcommands. -pub async fn launch_from( - session: &VortexSession, - args: impl IntoIterator + Clone>, -) -> anyhow::Result<()> { - let _ = env_logger::try_init(); - - let cli = Cli::parse_from(args); - - let path = cli.command.file_path(); - if !std::fs::exists(path)? { - Cli::command() - .error( - clap::error::ErrorKind::Io, - format!( - "File '{}' does not exist.", - path.to_str().vortex_expect("file path") - ), - ) - .exit() + impl Commands { + fn file_path(&self) -> &PathBuf { + match self { + Commands::Tree(args) => match &args.mode { + super::tree::TreeMode::Array { file, .. } => file, + super::tree::TreeMode::Layout { file, .. } => file, + }, + Commands::Browse { file } => file, + Commands::Convert(flags) => &flags.file, + Commands::Inspect(args) => &args.file, + Commands::Query(args) => &args.file, + Commands::Segments(args) => &args.file, + } + } } - match cli.command { - Commands::Tree(args) => tree::exec_tree(session, args).await?, - Commands::Convert(flags) => convert::exec_convert(session, flags).await?, - Commands::Browse { file } => browse::exec_tui(session, file).await?, - Commands::Inspect(args) => inspect::exec_inspect(session, args).await?, - Commands::Query(args) => query::exec_query(session, args).await?, - Commands::Segments(args) => segments::exec_segments(session, args).await?, - }; + /// Main entrypoint for `vx` that launches a [`VortexSession`]. + /// + /// Parses arguments from [`std::env::args_os`]. See [`launch_from`] to supply explicit arguments. + /// + /// # Errors + /// + /// Raises any errors from subcommands. + pub async fn launch(session: &VortexSession) -> anyhow::Result<()> { + launch_from(session, std::env::args_os()).await + } + + /// Launch `vx` with explicit command-line arguments. + /// + /// This is useful when embedding the TUI inside another process (e.g. Python) where + /// [`std::env::args`] may not reflect the intended arguments. + /// + /// # Errors + /// + /// Raises any errors from subcommands. + pub async fn launch_from( + session: &VortexSession, + args: impl IntoIterator + Clone>, + ) -> anyhow::Result<()> { + let _ = env_logger::try_init(); + + let cli = Cli::parse_from(args); - Ok(()) + let path = cli.command.file_path(); + if !std::fs::exists(path)? { + Cli::command() + .error( + clap::error::ErrorKind::Io, + format!( + "File '{}' does not exist.", + path.to_str().vortex_expect("file path") + ), + ) + .exit() + } + + match cli.command { + Commands::Tree(args) => super::tree::exec_tree(session, args).await?, + Commands::Convert(flags) => super::convert::exec_convert(session, flags).await?, + Commands::Browse { file } => super::browse::exec_tui(session, file).await?, + Commands::Inspect(args) => super::inspect::exec_inspect(session, args).await?, + Commands::Query(args) => super::query::exec_query(session, args).await?, + Commands::Segments(args) => super::segments::exec_segments(session, args).await?, + }; + + Ok(()) + } } + +#[cfg(not(target_arch = "wasm32"))] +pub use native_cli::launch; +#[cfg(not(target_arch = "wasm32"))] +pub use native_cli::launch_from; diff --git a/vortex-tui/src/main.rs b/vortex-tui/src/main.rs index d813ff4dc4f..573fae1bcb4 100644 --- a/vortex-tui/src/main.rs +++ b/vortex-tui/src/main.rs @@ -2,12 +2,14 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use vortex::VortexSessionDefault; +use vortex::io::runtime::BlockingRuntime; +use vortex::io::runtime::current::CurrentThreadRuntime; use vortex::io::session::RuntimeSessionExt; use vortex::session::VortexSession; use vortex_tui::launch; -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let session = VortexSession::default().with_tokio(); - launch(&session).await +fn main() -> anyhow::Result<()> { + let runtime = CurrentThreadRuntime::new(); + let session = VortexSession::default().with_handle(runtime.handle()); + runtime.block_on(launch(&session)) } diff --git a/vortex-tui/web/index.html b/vortex-tui/web/index.html new file mode 100644 index 00000000000..fbff123b4bc --- /dev/null +++ b/vortex-tui/web/index.html @@ -0,0 +1,111 @@ + + + + + + Vortex Browser + + + + + + + +
+ +

Drag and drop a .vtx file here to explore it

+

or

+ + +
+ + + + + diff --git a/vortex-tui/web/style.css b/vortex-tui/web/style.css new file mode 100644 index 00000000000..a848c89d58e --- /dev/null +++ b/vortex-tui/web/style.css @@ -0,0 +1,91 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + background: #101010; + color: #ECECEC; + font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + height: 100%; + overflow: hidden; +} + +canvas { + display: block; + position: fixed; + top: 0; + left: 0; +} + +#drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + border: 2px dashed #333; + margin: 24px; + border-radius: 12px; + transition: border-color 0.2s, background-color 0.2s; +} + +#drop-zone.drag-over { + border-color: #5971FD; + background-color: rgba(89, 113, 253, 0.08); +} + +#drop-zone .logo { + width: 200px; + margin-bottom: 32px; + filter: invert(1); +} + +#drop-zone p { + margin: 6px 0; + color: #888; + font-size: 0.95em; + line-height: 1.6; +} + +#drop-zone code { + background: #1a1a1a; + border: 1px solid #333; + padding: 2px 6px; + border-radius: 4px; + font-family: "Geist Mono", monospace; + font-size: 0.9em; + color: #CEE562; +} + +.file-label { + display: inline-block; + padding: 10px 28px; + background: #5971FD; + color: #ffffff; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + font-size: 0.95em; + margin-top: 12px; + transition: background 0.2s; +} + +.file-label:hover { + background: #4A5FE5; +} + +#file-input { + display: none; +} + +#loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + font-size: 1.1em; + color: #5971FD; +} From 521dc274e81bf4b9e2e185ca82cc524a284f363a Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 20:39:36 -0500 Subject: [PATCH 02/11] Add a web wrapper around vortex-tui Signed-off-by: Nicholas Gates --- vortex-tui/src/browse/input.rs | 115 +++++++++++++++++++++++++++++++++ vortex-tui/src/wasm.rs | 89 +++++++++++++++++++++++++ vortex-tui/web/logo.svg | 69 ++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 vortex-tui/src/browse/input.rs create mode 100644 vortex-tui/src/wasm.rs create mode 100644 vortex-tui/web/logo.svg diff --git a/vortex-tui/src/browse/input.rs b/vortex-tui/src/browse/input.rs new file mode 100644 index 00000000000..d08ee38ff99 --- /dev/null +++ b/vortex-tui/src/browse/input.rs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Platform-agnostic key event abstraction for the TUI browser. + +/// A platform-agnostic key event. +pub(crate) struct InputEvent { + /// The key code. + pub code: InputKeyCode, + /// Whether Ctrl is held. + pub ctrl: bool, + /// Whether Alt is held. + pub alt: bool, + /// Whether Shift is held. + pub shift: bool, +} + +/// A platform-agnostic key code. +pub(crate) enum InputKeyCode { + /// A character key. + Char(char), + /// Up arrow. + Up, + /// Down arrow. + Down, + /// Left arrow. + Left, + /// Right arrow. + Right, + /// Enter/Return. + Enter, + /// Escape. + Esc, + /// Tab. + Tab, + /// Page Up. + PageUp, + /// Page Down. + PageDown, + /// Home. + Home, + /// End. + End, + /// Backspace. + Backspace, + /// Delete. + Delete, + /// Any other unrecognized key. + Other, +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for InputEvent { + fn from(key: crossterm::event::KeyEvent) -> Self { + use crossterm::event::KeyCode; + use crossterm::event::KeyModifiers; + + let code = match key.code { + KeyCode::Char(c) => InputKeyCode::Char(c), + KeyCode::Up => InputKeyCode::Up, + KeyCode::Down => InputKeyCode::Down, + KeyCode::Left => InputKeyCode::Left, + KeyCode::Right => InputKeyCode::Right, + KeyCode::Enter => InputKeyCode::Enter, + KeyCode::Esc => InputKeyCode::Esc, + KeyCode::Tab => InputKeyCode::Tab, + KeyCode::PageUp => InputKeyCode::PageUp, + KeyCode::PageDown => InputKeyCode::PageDown, + KeyCode::Home => InputKeyCode::Home, + KeyCode::End => InputKeyCode::End, + KeyCode::Backspace => InputKeyCode::Backspace, + KeyCode::Delete => InputKeyCode::Delete, + _ => InputKeyCode::Other, + }; + + InputEvent { + code, + ctrl: key.modifiers.contains(KeyModifiers::CONTROL), + alt: key.modifiers.contains(KeyModifiers::ALT), + shift: key.modifiers.contains(KeyModifiers::SHIFT), + } + } +} + +#[cfg(target_arch = "wasm32")] +impl From for InputEvent { + fn from(key: ratzilla::event::KeyEvent) -> Self { + use ratzilla::event::KeyCode; + + let code = match key.code { + KeyCode::Char(c) => InputKeyCode::Char(c), + KeyCode::Up => InputKeyCode::Up, + KeyCode::Down => InputKeyCode::Down, + KeyCode::Left => InputKeyCode::Left, + KeyCode::Right => InputKeyCode::Right, + KeyCode::Enter => InputKeyCode::Enter, + KeyCode::Esc => InputKeyCode::Esc, + KeyCode::Tab => InputKeyCode::Tab, + KeyCode::PageUp => InputKeyCode::PageUp, + KeyCode::PageDown => InputKeyCode::PageDown, + KeyCode::Home => InputKeyCode::Home, + KeyCode::End => InputKeyCode::End, + KeyCode::Backspace => InputKeyCode::Backspace, + KeyCode::Delete => InputKeyCode::Delete, + _ => InputKeyCode::Other, + }; + + InputEvent { + code, + ctrl: key.ctrl, + alt: key.alt, + shift: key.shift, + } + } +} diff --git a/vortex-tui/src/wasm.rs b/vortex-tui/src/wasm.rs new file mode 100644 index 00000000000..9ca65206e70 --- /dev/null +++ b/vortex-tui/src/wasm.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! WebAssembly entry points for the Vortex TUI browser. +//! +//! Provides functions callable from JavaScript to load a Vortex file from a byte array +//! and display the interactive browser in a web page using ratzilla. + +use std::cell::RefCell; +use std::rc::Rc; + +use ratzilla::CanvasBackend; +use ratzilla::WebRenderer; +use ratzilla::ratatui::Terminal; +use wasm_bindgen::prelude::*; + +use crate::browse::app::AppState; +use crate::browse::app::KeyMode; +use crate::browse::handle_normal_mode; +use crate::browse::handle_search_mode; +use crate::browse::input::InputEvent; +use crate::browse::ui::render_app; + +/// Initialize the WASM module (sets up panic hook for better error messages). +#[wasm_bindgen(start)] +pub fn init() { + console_error_panic_hook::set_once(); +} + +/// Open a Vortex file from raw bytes and launch the interactive browser. +/// +/// Call this from JavaScript after reading a `.vtx` file (e.g. via drag-and-drop or file input). +/// The browser UI will be rendered into the DOM. +#[wasm_bindgen] +pub fn open_vortex_file(data: &[u8]) -> Result<(), JsValue> { + use vortex::VortexSessionDefault; + use vortex::buffer::ByteBuffer; + use vortex::io::runtime::wasm::WasmRuntime; + use vortex::io::session::RuntimeSessionExt; + use vortex::session::VortexSession; + + let session = VortexSession::default().with_handle(WasmRuntime::handle()); + let buffer = ByteBuffer::from(data.to_vec()); + let app = Rc::new(RefCell::new( + AppState::from_buffer(session, buffer).map_err(|e| JsValue::from_str(&e.to_string()))?, + )); + + // Size the canvas to the CSS viewport. The JS side then scales canvas.width by + // devicePixelRatio for crisp high-DPI rendering (ctx.scale(dpr)) without changing + // the cell count. Browser zoom changes the viewport, so zooming out gives more + // cols/rows and the resize handler recreates the terminal. + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let vw = window + .inner_width() + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(1200.0); + let vh = window + .inner_height() + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(800.0); + let backend = CanvasBackend::new_with_size(vw as u32, vh as u32) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let terminal = Terminal::new(backend).map_err(|e| JsValue::from_str(&e.to_string()))?; + + terminal.on_key_event({ + let app = app.clone(); + move |key_event| { + let mut app = app.borrow_mut(); + let input = InputEvent::from(key_event); + match app.key_mode { + KeyMode::Normal => { + handle_normal_mode(&mut app, input); + } + KeyMode::Search => { + handle_search_mode(&mut app, input); + } + } + } + }); + + terminal.draw_web(move |frame| { + let mut app = app.borrow_mut(); + render_app(&mut app, frame); + }); + + Ok(()) +} diff --git a/vortex-tui/web/logo.svg b/vortex-tui/web/logo.svg new file mode 100644 index 00000000000..f8210102998 --- /dev/null +++ b/vortex-tui/web/logo.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 4cdc62c3bf9f9661198a073a08bc6852e803e4a0 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 21:04:52 -0500 Subject: [PATCH 03/11] Add a web wrapper around vortex-tui Signed-off-by: Nicholas Gates --- vortex-tui/web/index.html | 26 ++++++++++++++++++++++++++ vortex-tui/web/style.css | 6 +----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/vortex-tui/web/index.html b/vortex-tui/web/index.html index fbff123b4bc..d465b526b98 100644 --- a/vortex-tui/web/index.html +++ b/vortex-tui/web/index.html @@ -82,6 +82,32 @@ resizeTimer = setTimeout(() => openFile(fileBytes), 150); }); + // Translate mouse wheel scroll into arrow key events for the TUI. + // Accumulate pixel deltas and emit one arrow key per LINE_HEIGHT crossed, + // same approach as xterm.js and other browser-based terminal emulators. + const LINE_HEIGHT = 60; + let scrollAccumulator = 0; + document.addEventListener('wheel', (e) => { + if (!fileBytes) return; + e.preventDefault(); + const delta = e.deltaMode === 1 ? e.deltaY * LINE_HEIGHT : e.deltaY; + scrollAccumulator += delta; + while (Math.abs(scrollAccumulator) >= LINE_HEIGHT) { + const key = scrollAccumulator < 0 ? 'ArrowUp' : 'ArrowDown'; + document.dispatchEvent(new KeyboardEvent('keydown', { key })); + scrollAccumulator -= Math.sign(scrollAccumulator) * LINE_HEIGHT; + } + }, { passive: false }); + + // Prevent browser shortcuts that conflict with TUI keybindings (e.g. Firefox + // quick-find on '/') + document.addEventListener('keydown', (e) => { + if (!fileBytes) return; + if (e.key === '/' || e.key === "'") { + e.preventDefault(); + } + }); + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); diff --git a/vortex-tui/web/style.css b/vortex-tui/web/style.css index a848c89d58e..558d2ae8cb7 100644 --- a/vortex-tui/web/style.css +++ b/vortex-tui/web/style.css @@ -25,15 +25,11 @@ canvas { flex-direction: column; align-items: center; justify-content: center; - min-height: 100vh; - border: 2px dashed #333; - margin: 24px; - border-radius: 12px; + height: 100%; transition: border-color 0.2s, background-color 0.2s; } #drop-zone.drag-over { - border-color: #5971FD; background-color: rgba(89, 113, 253, 0.08); } From ecc6624760a739f09757e328533f855381a3069c Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 22:41:17 -0500 Subject: [PATCH 04/11] mergE Signed-off-by: Nicholas Gates --- Cargo.lock | 1 + vortex-tui/Cargo.toml | 6 +- vortex-tui/public-api.lock | 2 - vortex-tui/src/browse/app.rs | 75 +++++++++--- vortex-tui/src/browse/input.rs | 51 ++++---- vortex-tui/src/browse/mod.rs | 66 ++++++++--- vortex-tui/src/browse/ui/layouts.rs | 64 +++++----- vortex-tui/src/browse/ui/mod.rs | 7 +- vortex-tui/src/browse/ui/query.rs | 174 +++++++++++++++++----------- vortex-tui/src/lib.rs | 10 +- vortex-tui/src/main.rs | 10 +- vortex-tui/src/wasm.rs | 125 +++++++++++++++++--- 12 files changed, 406 insertions(+), 185 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34824eaabae..1aabee2626a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,6 +1621,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix 1.1.4", diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 9348acd886e..bf9e296bf6a 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -43,7 +43,7 @@ required-features = ["native"] # Shared dependencies anyhow = { workspace = true } flatbuffers = { workspace = true } -futures = { workspace = true, features = ["executor"] } +futures = { workspace = true } fuzzy-matcher = { workspace = true } humansize = { workspace = true } itertools = { workspace = true } @@ -57,12 +57,12 @@ vortex = { version = "0.1.0", path = "../vortex", default-features = false, feat arrow-array = { workspace = true, optional = true } arrow-schema = { workspace = true, optional = true } clap = { workspace = true, features = ["derive"], optional = true } -crossterm = { workspace = true, optional = true } +crossterm = { workspace = true, features = ["event-stream"], optional = true } datafusion = { workspace = true, optional = true } env_logger = { version = "0.11", optional = true } indicatif = { workspace = true, features = ["futures"], optional = true } parquet = { workspace = true, features = ["arrow", "async"], optional = true } -tokio = { workspace = true, features = ["rt-multi-thread"], optional = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"], optional = true } vortex-datafusion = { workspace = true, optional = true } # WASM-only dependencies diff --git a/vortex-tui/public-api.lock b/vortex-tui/public-api.lock index bf9f7b41a17..efa1dee3b87 100644 --- a/vortex-tui/public-api.lock +++ b/vortex-tui/public-api.lock @@ -94,8 +94,6 @@ pub fn vortex_tui::browse::app::LayoutCursor::dtype(&self) -> &vortex_array::dty pub fn vortex_tui::browse::app::LayoutCursor::flat_layout_metadata_info(&self) -> alloc::string::String -pub fn vortex_tui::browse::app::LayoutCursor::flatbuffer_size(&self) -> usize - pub fn vortex_tui::browse::app::LayoutCursor::is_stats_table(&self) -> bool pub fn vortex_tui::browse::app::LayoutCursor::layout(&self) -> &vortex_layout::layout::LayoutRef diff --git a/vortex-tui/src/browse/app.rs b/vortex-tui/src/browse/app.rs index 6fa9701a30f..c81b234c261 100644 --- a/vortex-tui/src/browse/app.rs +++ b/vortex-tui/src/browse/app.rs @@ -5,10 +5,9 @@ use std::sync::Arc; -use futures::executor::block_on; use ratatui::prelude::Size; use ratatui::widgets::ListState; -use vortex::array::serde::ArrayParts; +use vortex::array::ArrayRef; use vortex::dtype::DType; use vortex::error::VortexExpect; use vortex::error::VortexResult; @@ -114,21 +113,6 @@ impl LayoutCursor { Self::new_with_path(self.footer.clone(), self.segment_source.clone(), path) } - /// Get the size of the array flatbuffer for this layout. - /// - /// # Panics - /// - /// Panics if the current layout is not a [`FlatVTable`] layout. - pub fn flatbuffer_size(&self) -> usize { - let segment_id = self.layout.as_::().segment_id(); - let segment = block_on(self.segment_source.request(segment_id)) - .vortex_expect("operation should succeed in TUI"); - ArrayParts::try_from(segment) - .vortex_expect("operation should succeed in TUI") - .metadata() - .len() - } - /// Get a human-readable description of the flat layout metadata. /// /// # Panics @@ -257,6 +241,12 @@ pub struct AppState { /// Vertical scroll offset for the encoding tree display in flat layout view. pub tree_scroll_offset: u16, + /// Cached array data for the current FlatLayout, loaded asynchronously on WASM. + pub cached_flat_array: Option, + + /// Cached flatbuffer size for the current FlatLayout, loaded asynchronously on WASM. + pub cached_flatbuffer_size: Option, + /// State for the Query tab. #[cfg(not(target_arch = "wasm32"))] pub query_state: super::ui::QueryState, @@ -302,6 +292,8 @@ impl AppState { segment_grid_state: SegmentGridState::default(), frame_size: Size::new(0, 0), tree_scroll_offset: 0, + cached_flat_array: None, + cached_flatbuffer_size: None, query_state: super::ui::QueryState::default(), file_path, }) @@ -334,6 +326,8 @@ impl AppState { segment_grid_state: SegmentGridState::default(), frame_size: Size::new(0, 0), tree_scroll_offset: 0, + cached_flat_array: None, + cached_flatbuffer_size: None, }) } @@ -346,8 +340,55 @@ impl AppState { /// Reset the layout view state after navigating to a different layout. /// /// This resets the list selection to the first item and clears any scroll offset. + /// The caller is responsible for awaiting [`load_flat_data()`] afterward if the + /// new layout is a [`FlatVTable`]. pub fn reset_layout_view_state(&mut self) { self.layouts_list_state = ListState::default().with_selected(Some(0)); self.tree_scroll_offset = 0; + self.cached_flat_array = None; + self.cached_flatbuffer_size = None; + } + + /// Asynchronously load and cache the flat layout array data and flatbuffer size. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn load_flat_data(&mut self) { + use vortex::array::MaskFuture; + use vortex::array::serde::ArrayParts; + use vortex::expr::root; + + let layout = &self.cursor.layout().clone(); + let row_count = layout.row_count(); + + // Load the array. + let reader = layout + .new_reader("".into(), self.vxf.segment_source(), &self.session) + .vortex_expect("Failed to create reader"); + let array = reader + .projection_evaluation( + &(0..row_count), + &root(), + MaskFuture::new_true( + usize::try_from(row_count).vortex_expect("row_count overflowed usize"), + ), + ) + .vortex_expect("Failed to construct projection") + .await + .vortex_expect("Failed to read flat array"); + self.cached_flat_array = Some(array); + + // Load the flatbuffer size. + let segment_id = layout.as_::().segment_id(); + let segment = self + .cursor + .segment_source + .request(segment_id) + .await + .vortex_expect("Failed to read segment"); + self.cached_flatbuffer_size = Some( + ArrayParts::try_from(segment) + .vortex_expect("Failed to parse segment") + .metadata() + .len(), + ); } } diff --git a/vortex-tui/src/browse/input.rs b/vortex-tui/src/browse/input.rs index d08ee38ff99..4508664399c 100644 --- a/vortex-tui/src/browse/input.rs +++ b/vortex-tui/src/browse/input.rs @@ -83,33 +83,38 @@ impl From for InputEvent { } #[cfg(target_arch = "wasm32")] -impl From for InputEvent { - fn from(key: ratzilla::event::KeyEvent) -> Self { - use ratzilla::event::KeyCode; - - let code = match key.code { - KeyCode::Char(c) => InputKeyCode::Char(c), - KeyCode::Up => InputKeyCode::Up, - KeyCode::Down => InputKeyCode::Down, - KeyCode::Left => InputKeyCode::Left, - KeyCode::Right => InputKeyCode::Right, - KeyCode::Enter => InputKeyCode::Enter, - KeyCode::Esc => InputKeyCode::Esc, - KeyCode::Tab => InputKeyCode::Tab, - KeyCode::PageUp => InputKeyCode::PageUp, - KeyCode::PageDown => InputKeyCode::PageDown, - KeyCode::Home => InputKeyCode::Home, - KeyCode::End => InputKeyCode::End, - KeyCode::Backspace => InputKeyCode::Backspace, - KeyCode::Delete => InputKeyCode::Delete, - _ => InputKeyCode::Other, +impl From for InputEvent { + fn from(event: web_sys::KeyboardEvent) -> Self { + let key = event.key(); + let code = if key.len() == 1 { + match key.chars().next() { + Some(c) => InputKeyCode::Char(c), + None => InputKeyCode::Other, + } + } else { + match key.as_str() { + "ArrowUp" => InputKeyCode::Up, + "ArrowDown" => InputKeyCode::Down, + "ArrowLeft" => InputKeyCode::Left, + "ArrowRight" => InputKeyCode::Right, + "Enter" => InputKeyCode::Enter, + "Escape" => InputKeyCode::Esc, + "Tab" => InputKeyCode::Tab, + "PageUp" => InputKeyCode::PageUp, + "PageDown" => InputKeyCode::PageDown, + "Home" => InputKeyCode::Home, + "End" => InputKeyCode::End, + "Backspace" => InputKeyCode::Backspace, + "Delete" => InputKeyCode::Delete, + _ => InputKeyCode::Other, + } }; InputEvent { code, - ctrl: key.ctrl, - alt: key.alt, - shift: key.shift, + ctrl: event.ctrl_key(), + alt: event.alt_key(), + shift: event.shift_key(), } } } diff --git a/vortex-tui/src/browse/mod.rs b/vortex-tui/src/browse/mod.rs index bf186ac1264..a18a6dfcfda 100644 --- a/vortex-tui/src/browse/mod.rs +++ b/vortex-tui/src/browse/mod.rs @@ -78,9 +78,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl (InputKeyCode::Enter, ..) => { app.query_state.sort_column = None; app.query_state.sort_direction = SortDirection::None; - let file_path = app.file_path.clone(); - app.query_state - .execute_initial_query(&app.session, &file_path); + app.query_state.prepare_initial_query(); app.query_state.focus = QueryFocus::ResultsTable; } (InputKeyCode::Left, ..) => app.query_state.move_cursor_left(), @@ -123,16 +121,14 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl #[cfg(not(target_arch = "wasm32"))] (InputKeyCode::Char('['), false, false, _) => { if app.current_tab == Tab::Query { - app.query_state - .prev_page(&app.session, &app.file_path.clone()); + app.query_state.prepare_prev_page(); } } #[cfg(not(target_arch = "wasm32"))] (InputKeyCode::Char(']'), false, false, _) => { if app.current_tab == Tab::Query { - app.query_state - .next_page(&app.session, &app.file_path.clone()); + app.query_state.prepare_next_page(); } } @@ -162,8 +158,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE), #[cfg(not(target_arch = "wasm32"))] Tab::Query => { - app.query_state - .prev_page(&app.session, &app.file_path.clone()); + app.query_state.prepare_prev_page(); } } } @@ -173,8 +168,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE), #[cfg(not(target_arch = "wasm32"))] Tab::Query => { - app.query_state - .next_page(&app.session, &app.file_path.clone()); + app.query_state.prepare_next_page(); } } } @@ -254,8 +248,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl (InputKeyCode::Char('s'), false, false, _) => { if app.current_tab == Tab::Query { let col = app.query_state.selected_column(); - app.query_state - .apply_sort(&app.session, col, &app.file_path); + app.query_state.prepare_sort(col); } } @@ -355,9 +348,10 @@ pub(crate) fn handle_search_mode(app: &mut AppState, event: InputEvent) -> Handl #[cfg(not(target_arch = "wasm32"))] mod native { - use crossterm::event; use crossterm::event::Event; + use crossterm::event::EventStream; use crossterm::event::KeyEventKind; + use futures::StreamExt; use ratatui::DefaultTerminal; use vortex::error::VortexResult; use vortex::session::VortexSession; @@ -366,10 +360,43 @@ mod native { use super::*; async fn run(mut terminal: DefaultTerminal, mut app: AppState) -> VortexResult<()> { + // Eagerly load data if the initial layout is flat. + if app.cursor.layout().is::() { + app.load_flat_data().await; + } + + let mut events = EventStream::new(); loop { terminal.draw(|frame| render_app(&mut app, frame))?; - let raw_event = event::read()?; + // Take the pending query receiver so we can select! on it + // without holding a mutable borrow on app. + let pending_rx = app.query_state.pending_rx.take(); + + let event = if let Some(mut rx) = pending_rx { + tokio::select! { + event = events.next() => { + // No query result yet — put the receiver back. + app.query_state.pending_rx = Some(rx); + event + } + result = &mut rx => { + if let Ok(result) = result { + app.query_state.apply_query_result(result); + } + // Re-render immediately to show updated results. + continue; + } + } + } else { + events.next().await + }; + + let Some(raw_event) = event else { + break; + }; + let raw_event = raw_event?; + if let Event::Key(key) = raw_event { if key.kind != KeyEventKind::Press { continue; @@ -384,8 +411,17 @@ mod native { if matches!(result, HandleResult::Exit) { return Ok(()); } + + // After handling, load flat data if we navigated to a FlatLayout. + if app.cursor.layout().is::() && app.cached_flat_array.is_none() { + app.load_flat_data().await; + } + + // Spawn any pending query execution as a background task. + app.query_state.spawn_pending(&app.session, &app.file_path); } } + Ok(()) } /// Launch the interactive TUI browser for a Vortex file. diff --git a/vortex-tui/src/browse/ui/layouts.rs b/vortex-tui/src/browse/ui/layouts.rs index 42d9c6e908a..c14ef6c9cbf 100644 --- a/vortex-tui/src/browse/ui/layouts.rs +++ b/vortex-tui/src/browse/ui/layouts.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use futures::executor::block_on; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use humansize::DECIMAL; @@ -29,15 +28,12 @@ use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use vortex::array::Array; use vortex::array::ArrayRef; -use vortex::array::MaskFuture; use vortex::array::ToCanonical; use vortex::error::VortexExpect; -use vortex::expr::root; use vortex::layout::layouts::flat::FlatVTable; use vortex::layout::layouts::zoned::ZonedVTable; use crate::browse::app::AppState; -use crate::browse::app::LayoutCursor; /// Render the Layouts tab. pub fn render_layouts(app_state: &mut AppState, area: Rect, buf: &mut Buffer) { @@ -45,7 +41,7 @@ pub fn render_layouts(app_state: &mut AppState, area: Rect, buf: &mut Buffer) { Layout::vertical([Constraint::Length(10), Constraint::Min(1)]).areas(area); // Render the header area. - render_layout_header(&app_state.cursor, header_area, buf); + render_layout_header(app_state, header_area, buf); // Render the list view if the layout has children if app_state.cursor.layout().is::() { @@ -60,7 +56,8 @@ pub fn render_layouts(app_state: &mut AppState, area: Rect, buf: &mut Buffer) { } } -fn render_layout_header(cursor: &LayoutCursor, area: Rect, buf: &mut Buffer) { +fn render_layout_header(app: &AppState, area: Rect, buf: &mut Buffer) { + let cursor = &app.cursor; let layout_id = cursor.layout().encoding_id(); let row_count = cursor.layout().row_count(); let size_formatter = make_format(DECIMAL); @@ -77,10 +74,12 @@ fn render_layout_header(cursor: &LayoutCursor, area: Rect, buf: &mut Buffer) { ]; if cursor.layout().is::() { - rows.push(Text::from(format!( - "FlatBuffer Size: {}", - size_formatter(cursor.flatbuffer_size()) - ))); + if let Some(fb_size) = app.cached_flatbuffer_size { + rows.push(Text::from(format!( + "FlatBuffer Size: {}", + size_formatter(fb_size) + ))); + } // Display metadata info about the flat layout let metadata_info = cursor.flat_layout_metadata_info(); @@ -114,26 +113,18 @@ fn render_layout_header(cursor: &LayoutCursor, area: Rect, buf: &mut Buffer) { /// Render the inner Array for a FlatLayout. fn render_array(app: &AppState, area: Rect, buf: &mut Buffer, is_stats_table: bool) { - let row_count = app.cursor.layout().row_count(); - let reader = app - .cursor - .layout() - .new_reader("".into(), app.vxf.segment_source(), &app.session) - .vortex_expect("Failed to create reader"); - - // FIXME(ngates): our TUI app should never perform I/O in the render loop... - let array = block_on( - reader - .projection_evaluation( - &(0..row_count), - &root(), - MaskFuture::new_true( - usize::try_from(row_count).vortex_expect("row_count overflowed usize"), - ), - ) - .vortex_expect("Failed to construct projection"), - ) - .vortex_expect("Failed to read flat array"); + // Array data is loaded eagerly when navigating to a FlatLayout (synchronously on + // native, asynchronously on WASM) and cached in AppState. The render loop never + // performs I/O. + let array = match app.cached_flat_array.as_ref() { + Some(arr) => arr.clone(), + None => { + let loading = + Paragraph::new("Loading array data...").style(Style::default().fg(Color::DarkGray)); + Widget::render(loading, area, buf); + return; + } + }; // Show the metadata as JSON. (show count of encoded bytes as well) // let metadata_size = array.metadata_bytes().unwrap_or_default().len(); @@ -156,7 +147,11 @@ fn render_array(app: &AppState, area: Rect, buf: &mut Buffer, is_stats_table: bo .chain(struct_array.names().iter().map(|x| x.as_ref())) .map(Cell::from) .collect::() - .style(Style::default().fg(Color::Rgb(206, 229, 98)).bg(Color::DarkGray)) + .style( + Style::default() + .fg(Color::Rgb(206, 229, 98)) + .bg(Color::DarkGray), + ) .height(1); assert_eq!(app.cursor.dtype(), array.dtype()); @@ -301,7 +296,12 @@ fn render_child_list_items( // Render the List view. StatefulWidget::render( - List::new(list_items).highlight_style(Style::default().fg(Color::Rgb(16, 16, 16)).bg(Color::Rgb(89, 113, 253)).bold()), + List::new(list_items).highlight_style( + Style::default() + .fg(Color::Rgb(16, 16, 16)) + .bg(Color::Rgb(89, 113, 253)) + .bold(), + ), inner_area, buf, &mut app.layouts_list_state, diff --git a/vortex-tui/src/browse/ui/mod.rs b/vortex-tui/src/browse/ui/mod.rs index b9e8e4a7455..9f5ca7c4894 100644 --- a/vortex-tui/src/browse/ui/mod.rs +++ b/vortex-tui/src/browse/ui/mod.rs @@ -93,7 +93,12 @@ pub fn render_app(app: &mut AppState, frame: &mut Frame<'_>) { let tabs = Tabs::new(tab_names) .style(Style::default().bold().white()) - .highlight_style(Style::default().bold().fg(Color::Rgb(16, 16, 16)).bg(Color::Rgb(89, 113, 253))) + .highlight_style( + Style::default() + .bold() + .fg(Color::Rgb(16, 16, 16)) + .bg(Color::Rgb(89, 113, 253)), + ) .select(Some(selected_tab)); frame.render_widget(tabs, tab_view); diff --git a/vortex-tui/src/browse/ui/query.rs b/vortex-tui/src/browse/ui/query.rs index abd8846dd56..3c626b47207 100644 --- a/vortex-tui/src/browse/ui/query.rs +++ b/vortex-tui/src/browse/ui/query.rs @@ -23,7 +23,8 @@ use ratatui::widgets::StatefulWidget; use ratatui::widgets::Table; use ratatui::widgets::TableState; use ratatui::widgets::Widget; -use tokio::runtime::Runtime; +use tokio::sync::oneshot; +use vortex::session::VortexSession; use crate::browse::app::AppState; use crate::datafusion_helper::arrow_value_to_json; @@ -72,6 +73,12 @@ pub enum QueryFocus { ResultsTable, } +/// Result from a background query task. +pub(crate) struct PendingQueryResult { + pub row_count: Option>, + pub query_result: Result, +} + /// State for the SQL query interface. pub struct QueryState { /// The SQL query input text. @@ -104,6 +111,12 @@ pub struct QueryState { pub base_query: String, /// ORDER BY clause if any. pub order_clause: Option, + /// Whether a query execution is pending (needs to be spawned). + pending_execution: bool, + /// Whether a row count query is needed on next spawn. + needs_row_count: bool, + /// Receiver for in-flight background query result. + pub(crate) pending_rx: Option>, } impl Default for QueryState { @@ -125,6 +138,9 @@ impl Default for QueryState { total_row_count: None, base_query: "SELECT * FROM data".to_string(), order_clause: None, + pending_execution: false, + needs_row_count: false, + pending_rx: None, } } } @@ -187,13 +203,8 @@ impl QueryState { }; } - /// Execute initial query - parses SQL, gets total count, fetches first page. - pub fn execute_initial_query( - &mut self, - session: &vortex::session::VortexSession, - file_path: &str, - ) { - self.running = true; + /// Prepare initial query - parses SQL, sets flags for async execution. + pub fn prepare_initial_query(&mut self) { self.error = None; // Parse the SQL to extract base query, order clause, and page size @@ -203,27 +214,24 @@ impl QueryState { self.page_size = limit.unwrap_or(20); self.current_page = 0; - // Get total row count - self.total_row_count = get_row_count(session, file_path, &self.base_query).ok(); - - // Build and execute the query - self.rebuild_and_execute(session, file_path); + self.needs_row_count = true; + self.rebuild_sql(); } - /// Navigate to next page. - pub fn next_page(&mut self, session: &vortex::session::VortexSession, file_path: &str) { + /// Prepare navigation to next page. + pub fn prepare_next_page(&mut self) { let total_pages = self.total_pages(); if self.current_page + 1 < total_pages { self.current_page += 1; - self.rebuild_and_execute(session, file_path); + self.rebuild_sql(); } } - /// Navigate to previous page. - pub fn prev_page(&mut self, session: &vortex::session::VortexSession, file_path: &str) { + /// Prepare navigation to previous page. + pub fn prepare_prev_page(&mut self) { if self.current_page > 0 { self.current_page -= 1; - self.rebuild_and_execute(session, file_path); + self.rebuild_sql(); } } @@ -235,8 +243,8 @@ impl QueryState { } } - /// Build SQL query from current state and execute it. - fn rebuild_and_execute(&mut self, session: &vortex::session::VortexSession, file_path: &str) { + /// Build SQL query from current state and set the pending execution flag. + fn rebuild_sql(&mut self) { let offset = self.current_page * self.page_size; let new_sql = match &self.order_clause { @@ -259,8 +267,49 @@ impl QueryState { self.running = true; self.error = None; + self.pending_execution = true; + } - match execute_query(session, file_path, &self.sql_input) { + /// Spawn a background task for the pending query, if any. + /// + /// After calling `prepare_*` methods, call this to kick off execution. + /// The result will arrive on [`pending_rx`] and should be applied with + /// [`apply_query_result`]. + pub(crate) fn spawn_pending(&mut self, session: &VortexSession, file_path: &str) { + if !self.pending_execution { + return; + } + self.pending_execution = false; + + let (tx, rx) = oneshot::channel(); + let session = session.clone(); + let file_path = file_path.to_string(); + let sql = self.sql_input.clone(); + let base_query = self.base_query.clone(); + let needs_row_count = self.needs_row_count; + self.needs_row_count = false; + + tokio::spawn(async move { + let row_count = match needs_row_count { + true => Some(get_row_count(&session, &file_path, &base_query).await), + false => None, + }; + let query_result = execute_query(&session, &file_path, &sql).await; + drop(tx.send(PendingQueryResult { + row_count, + query_result, + })); + }); + + self.pending_rx = Some(rx); + } + + /// Apply a completed background query result to the state. + pub(crate) fn apply_query_result(&mut self, result: PendingQueryResult) { + if let Some(row_count) = result.row_count { + self.total_row_count = row_count.ok(); + } + match result.query_result { Ok(results) => { self.results = Some(results); self.table_state.select(Some(0)); @@ -345,13 +394,8 @@ impl QueryState { .unwrap_or(0) } - /// Apply sort on a column by modifying the ORDER BY clause and re-executing. - pub fn apply_sort( - &mut self, - session: &vortex::session::VortexSession, - column: usize, - file_path: &str, - ) { + /// Prepare sort on a column by modifying the ORDER BY clause and setting execution flag. + pub fn prepare_sort(&mut self, column: usize) { // Get the column name from results let column_name = match &self.results { Some(results) if column < results.column_names.len() => { @@ -383,9 +427,9 @@ impl QueryState { Some(format!("ORDER BY \"{column_name}\" {direction}")) }; - // Reset to first page and re-execute + // Reset to first page and set pending execution self.current_page = 0; - self.rebuild_and_execute(session, file_path); + self.rebuild_sql(); } } @@ -397,58 +441,52 @@ pub struct QueryResults { } /// Execute a SQL query against the Vortex file. -pub fn execute_query( - session: &vortex::session::VortexSession, +async fn execute_query( + session: &VortexSession, file_path: &str, sql: &str, ) -> Result { - let rt = Runtime::new().map_err(|e| format!("Failed to create tokio runtime: {e}"))?; - rt.block_on(async { - let batches = execute_vortex_query(session, file_path, sql).await?; + let batches = execute_vortex_query(session, file_path, sql).await?; - let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); + let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum(); - let column_names = if let Some(batch) = batches.first() { - let schema = batch.schema(); - schema.fields().iter().map(|f| f.name().clone()).collect() - } else { - vec![] - }; + let column_names = if let Some(batch) = batches.first() { + let schema = batch.schema(); + schema.fields().iter().map(|f| f.name().clone()).collect() + } else { + vec![] + }; - Ok(QueryResults { - batches, - total_rows, - column_names, - }) + Ok(QueryResults { + batches, + total_rows, + column_names, }) } /// Get total row count for a base query using COUNT(*). -pub fn get_row_count( - session: &vortex::session::VortexSession, +async fn get_row_count( + session: &VortexSession, file_path: &str, base_query: &str, ) -> Result { - let rt = Runtime::new().map_err(|e| format!("Failed to create tokio runtime: {e}"))?; - rt.block_on(async { - let count_sql = format!("SELECT COUNT(*) as count FROM ({base_query}) AS subquery"); - - let batches = execute_vortex_query(session, file_path, &count_sql).await?; - - // Extract count from result - if let Some(batch) = batches.first() - && batch.num_rows() > 0 - && batch.num_columns() > 0 - { - use arrow_array::Int64Array; - if let Some(arr) = batch.column(0).as_any().downcast_ref::() { - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - return Ok(arr.value(0) as usize); - } + let count_sql = format!("SELECT COUNT(*) as count FROM ({base_query}) AS subquery"); + + let batches = execute_vortex_query(session, file_path, &count_sql).await?; + + // Extract count from result + if let Some(batch) = batches.first() + && batch.num_rows() > 0 + && batch.num_columns() > 0 + { + use arrow_array::Int64Array; + if let Some(arr) = batch.column(0).as_any().downcast_ref::() { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + return Ok(arr.value(0) as usize); } + } - Ok(0) - }) + Ok(0) } /// Render the Query tab UI. diff --git a/vortex-tui/src/lib.rs b/vortex-tui/src/lib.rs index 0f19db0f847..6edf4c07c02 100644 --- a/vortex-tui/src/lib.rs +++ b/vortex-tui/src/lib.rs @@ -10,13 +10,15 @@ //! //! ```ignore //! use vortex::session::VortexSession; -//! use vortex::io::runtime::current::CurrentThreadRuntime; //! use vortex::io::session::RuntimeSessionExt; //! use vortex_tui::browse; //! -//! let runtime = CurrentThreadRuntime::new(); -//! let session = VortexSession::default().with_handle(runtime.handle()); -//! runtime.block_on(browse::exec_tui(&session, "my_file.vortex"))?; +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let session = VortexSession::default().with_tokio(); +//! browse::exec_tui(&session, "my_file.vortex").await?; +//! Ok(()) +//! } //! ``` #![deny(clippy::missing_errors_doc)] diff --git a/vortex-tui/src/main.rs b/vortex-tui/src/main.rs index 573fae1bcb4..d813ff4dc4f 100644 --- a/vortex-tui/src/main.rs +++ b/vortex-tui/src/main.rs @@ -2,14 +2,12 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use vortex::VortexSessionDefault; -use vortex::io::runtime::BlockingRuntime; -use vortex::io::runtime::current::CurrentThreadRuntime; use vortex::io::session::RuntimeSessionExt; use vortex::session::VortexSession; use vortex_tui::launch; -fn main() -> anyhow::Result<()> { - let runtime = CurrentThreadRuntime::new(); - let session = VortexSession::default().with_handle(runtime.handle()); - runtime.block_on(launch(&session)) +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let session = VortexSession::default().with_tokio(); + launch(&session).await } diff --git a/vortex-tui/src/wasm.rs b/vortex-tui/src/wasm.rs index 9ca65206e70..07b691d72d5 100644 --- a/vortex-tui/src/wasm.rs +++ b/vortex-tui/src/wasm.rs @@ -8,10 +8,18 @@ use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; use ratzilla::CanvasBackend; use ratzilla::WebRenderer; use ratzilla::ratatui::Terminal; +use vortex::array::MaskFuture; +use vortex::array::serde::ArrayParts; +use vortex::error::VortexExpect; +use vortex::expr::root; +use vortex::layout::layouts::flat::FlatVTable; +use vortex::layout::segments::SegmentSource; +use vortex::session::VortexSession; use wasm_bindgen::prelude::*; use crate::browse::app::AppState; @@ -27,6 +35,79 @@ pub fn init() { console_error_panic_hook::set_once(); } +/// Spawn an async task to load the flat layout array data and cache it in AppState. +/// +/// This avoids calling `block_on()` in the render loop, which would deadlock in WASM +/// since the single-threaded event loop can't process spawned tasks while busy-waiting. +fn maybe_load_flat_data(app: &Rc>) { + let borrowed = app.borrow(); + if !borrowed.cursor.layout().is::() { + return; + } + if borrowed.cached_flat_array.is_some() { + return; + } + + // Extract everything we need before dropping the borrow. + let layout = borrowed.cursor.layout().clone(); + let segment_source = borrowed.vxf.segment_source(); + let session = borrowed.session.clone(); + let row_count = layout.row_count(); + drop(borrowed); + + let app = app.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Load the array data. + let array = load_flat_array(&layout, &segment_source, &session, row_count).await; + + // Load the flatbuffer size. + let fb_size = load_flatbuffer_size(&layout, &segment_source).await; + + // Store results — the borrow is safe because spawn_local runs between + // animation frames, so the draw_web callback won't be holding it. + let mut app = app.borrow_mut(); + app.cached_flat_array = Some(array); + app.cached_flatbuffer_size = Some(fb_size); + }); +} + +async fn load_flat_array( + layout: &vortex::layout::LayoutRef, + segment_source: &Arc, + session: &VortexSession, + row_count: u64, +) -> vortex::array::ArrayRef { + let reader = layout + .new_reader("".into(), segment_source.clone(), session) + .vortex_expect("Failed to create reader"); + reader + .projection_evaluation( + &(0..row_count), + &root(), + MaskFuture::new_true( + usize::try_from(row_count).vortex_expect("row_count overflowed usize"), + ), + ) + .vortex_expect("Failed to construct projection") + .await + .vortex_expect("Failed to read flat array") +} + +async fn load_flatbuffer_size( + layout: &vortex::layout::LayoutRef, + segment_source: &Arc, +) -> usize { + let segment_id = layout.as_::().segment_id(); + let segment = segment_source + .request(segment_id) + .await + .vortex_expect("Failed to read segment"); + ArrayParts::try_from(segment) + .vortex_expect("Failed to parse segment") + .metadata() + .len() +} + /// Open a Vortex file from raw bytes and launch the interactive browser. /// /// Call this from JavaScript after reading a `.vtx` file (e.g. via drag-and-drop or file input). @@ -37,7 +118,6 @@ pub fn open_vortex_file(data: &[u8]) -> Result<(), JsValue> { use vortex::buffer::ByteBuffer; use vortex::io::runtime::wasm::WasmRuntime; use vortex::io::session::RuntimeSessionExt; - use vortex::session::VortexSession; let session = VortexSession::default().with_handle(WasmRuntime::handle()); let buffer = ByteBuffer::from(data.to_vec()); @@ -64,21 +144,38 @@ pub fn open_vortex_file(data: &[u8]) -> Result<(), JsValue> { .map_err(|e| JsValue::from_str(&e.to_string()))?; let terminal = Terminal::new(backend).map_err(|e| JsValue::from_str(&e.to_string()))?; - terminal.on_key_event({ - let app = app.clone(); - move |key_event| { - let mut app = app.borrow_mut(); - let input = InputEvent::from(key_event); - match app.key_mode { - KeyMode::Normal => { - handle_normal_mode(&mut app, input); - } - KeyMode::Search => { - handle_search_mode(&mut app, input); + // Register our own keydown listener so we can call preventDefault() for keys + // the browser would otherwise intercept (e.g. Tab switching focus, '/' opening + // Firefox's quick-find). + { + let app_for_keys = app.clone(); + let closure = Closure::::new(move |event: web_sys::KeyboardEvent| { + event.prevent_default(); + + { + let mut app_mut = app_for_keys.borrow_mut(); + let input = InputEvent::from(event); + match app_mut.key_mode { + KeyMode::Normal => { + handle_normal_mode(&mut app_mut, input); + } + KeyMode::Search => { + handle_search_mode(&mut app_mut, input); + } } } - } - }); + // After handling the key event, trigger async loading if we navigated + // to a FlatLayout. + maybe_load_flat_data(&app_for_keys); + }); + let document = window + .document() + .ok_or_else(|| JsValue::from_str("no document"))?; + document + .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()) + .map_err(|e| JsValue::from_str(&format!("addEventListener failed: {e:?}")))?; + closure.forget(); + } terminal.draw_web(move |frame| { let mut app = app.borrow_mut(); From ce00b949fb2f9bfd0b4350fa0c2bf20b85e27c32 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 22:52:26 -0500 Subject: [PATCH 05/11] mergE Signed-off-by: Nicholas Gates --- vortex-tui/Cargo.toml | 11 ++++++++--- vortex-tui/Makefile | 3 +++ vortex-tui/src/browse/app.rs | 2 +- vortex-tui/src/lib.rs | 2 +- vortex-tui/web/index.html | 4 ++++ vortex-tui/web/logo.svg | 4 ++++ vortex-tui/web/style.css | 3 +++ 7 files changed, 24 insertions(+), 5 deletions(-) diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index bf9e296bf6a..301829a4bb4 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -51,7 +51,9 @@ ratatui = { version = "0.30", default-features = false } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } taffy = { workspace = true } -vortex = { version = "0.1.0", path = "../vortex", default-features = false, features = ["files"] } +vortex = { version = "0.1.0", path = "../vortex", default-features = false, features = [ + "files", +] } # Native-only dependencies (gated behind "native" feature) arrow-array = { workspace = true, optional = true } @@ -62,12 +64,15 @@ datafusion = { workspace = true, optional = true } env_logger = { version = "0.11", optional = true } indicatif = { workspace = true, features = ["futures"], optional = true } parquet = { workspace = true, features = ["arrow", "async"], optional = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros"], optional = true } +tokio = { workspace = true, features = [ + "rt-multi-thread", + "macros", +], optional = true } vortex-datafusion = { workspace = true, optional = true } # WASM-only dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -console_error_panic_hook = "0.1" +console_error_panic_hook = "0.1.7" js-sys = "0.3" ratzilla = "0.3" wasm-bindgen = "0.2" diff --git a/vortex-tui/Makefile b/vortex-tui/Makefile index 3fb7d729212..317ed0ac610 100644 --- a/vortex-tui/Makefile +++ b/vortex-tui/Makefile @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + .PHONY: serve clean-web web/pkg: src/**/*.rs Cargo.toml diff --git a/vortex-tui/src/browse/app.rs b/vortex-tui/src/browse/app.rs index c81b234c261..2bb53692bfa 100644 --- a/vortex-tui/src/browse/app.rs +++ b/vortex-tui/src/browse/app.rs @@ -340,7 +340,7 @@ impl AppState { /// Reset the layout view state after navigating to a different layout. /// /// This resets the list selection to the first item and clears any scroll offset. - /// The caller is responsible for awaiting [`load_flat_data()`] afterward if the + /// The caller is responsible for awaiting `load_flat_data()` afterward if the /// new layout is a [`FlatVTable`]. pub fn reset_layout_view_state(&mut self) { self.layouts_list_state = ListState::default().with_selected(Some(0)); diff --git a/vortex-tui/src/lib.rs b/vortex-tui/src/lib.rs index 6edf4c07c02..2c47cbd47ec 100644 --- a/vortex-tui/src/lib.rs +++ b/vortex-tui/src/lib.rs @@ -4,7 +4,7 @@ //! Vortex TUI library for interactively browsing and inspecting Vortex files. //! //! This crate provides both a CLI tool (`vx`) and a library API for working with Vortex files. -//! Users can bring their own [`VortexSession`] to enable custom encodings and extensions. +//! Users can bring their own [`vortex::VortexSession`] to enable custom encodings and extensions. //! //! # Example //! diff --git a/vortex-tui/web/index.html b/vortex-tui/web/index.html index d465b526b98..5ff3572d200 100644 --- a/vortex-tui/web/index.html +++ b/vortex-tui/web/index.html @@ -1,3 +1,7 @@ + diff --git a/vortex-tui/web/logo.svg b/vortex-tui/web/logo.svg index f8210102998..a98a0b58508 100644 --- a/vortex-tui/web/logo.svg +++ b/vortex-tui/web/logo.svg @@ -1,3 +1,7 @@ + diff --git a/vortex-tui/web/style.css b/vortex-tui/web/style.css index 558d2ae8cb7..c6eaccbeca7 100644 --- a/vortex-tui/web/style.css +++ b/vortex-tui/web/style.css @@ -1,3 +1,6 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* SPDX-FileCopyrightText: Copyright the Vortex contributors */ + * { margin: 0; padding: 0; From 03dc6e8f260ac01a544fd147b483e8a6c519924c Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 23:05:28 -0500 Subject: [PATCH 06/11] mergE Signed-off-by: Nicholas Gates --- _typos.toml | 2 +- vortex-tui/Cargo.toml | 2 +- vortex-tui/src/browse/app.rs | 15 ++++++------- vortex-tui/src/browse/input.rs | 2 +- vortex-tui/src/browse/mod.rs | 40 ++++++++++++++++----------------- vortex-tui/src/browse/ui/mod.rs | 16 ++++++------- vortex-tui/src/lib.rs | 24 +++++++++++--------- 7 files changed, 52 insertions(+), 49 deletions(-) diff --git a/_typos.toml b/_typos.toml index 5d601d1036f..7dcb181ec49 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,5 +1,5 @@ [default] -extend-ignore-identifiers-re = ["ffor", "FFOR", "FoR", "typ"] +extend-ignore-identifiers-re = ["ffor", "FFOR", "FoR", "typ", "ratatui"] # We support a few common special comments to tell the checker to ignore sections of code extend-ignore-re = [ "(#|//)\\s*spellchecker:ignore-next-line\\n.*", # Ignore the next line diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 301829a4bb4..5cd75136086 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -77,7 +77,7 @@ js-sys = "0.3" ratzilla = "0.3" wasm-bindgen = "0.2" wasm-bindgen-futures = { workspace = true } -web-sys = { version = "0.3", features = [ +web-sys = { version = "0.3.81", features = [ "console", "DataTransfer", "Document", diff --git a/vortex-tui/src/browse/app.rs b/vortex-tui/src/browse/app.rs index 2bb53692bfa..b7cd1767eb0 100644 --- a/vortex-tui/src/browse/app.rs +++ b/vortex-tui/src/browse/app.rs @@ -10,7 +10,6 @@ use ratatui::widgets::ListState; use vortex::array::ArrayRef; use vortex::dtype::DType; use vortex::error::VortexExpect; -use vortex::error::VortexResult; use vortex::file::Footer; use vortex::file::SegmentSpec; use vortex::file::VortexFile; @@ -40,7 +39,7 @@ pub enum Tab { Segments, /// SQL query interface powered by DataFusion. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Query, } @@ -248,11 +247,11 @@ pub struct AppState { pub cached_flatbuffer_size: Option, /// State for the Query tab. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] pub query_state: super::ui::QueryState, /// File path for use in query execution. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] pub file_path: String, } @@ -262,11 +261,11 @@ impl AppState { /// # Errors /// /// Returns an error if the file cannot be opened or read. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] pub async fn new( session: &VortexSession, path: impl AsRef, - ) -> VortexResult { + ) -> vortex::error::VortexResult { use vortex::file::OpenOptionsSessionExt; let session = session.clone(); @@ -308,7 +307,7 @@ impl AppState { pub fn from_buffer( session: VortexSession, buffer: vortex::buffer::ByteBuffer, - ) -> VortexResult { + ) -> vortex::error::VortexResult { use vortex::file::OpenOptionsSessionExt; let vxf = session.open_options().open_buffer(buffer)?; @@ -350,7 +349,7 @@ impl AppState { } /// Asynchronously load and cache the flat layout array data and flatbuffer size. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] pub(crate) async fn load_flat_data(&mut self) { use vortex::array::MaskFuture; use vortex::array::serde::ArrayParts; diff --git a/vortex-tui/src/browse/input.rs b/vortex-tui/src/browse/input.rs index 4508664399c..fbcef0a516a 100644 --- a/vortex-tui/src/browse/input.rs +++ b/vortex-tui/src/browse/input.rs @@ -49,7 +49,7 @@ pub(crate) enum InputKeyCode { Other, } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] impl From for InputEvent { fn from(key: crossterm::event::KeyEvent) -> Self { use crossterm::event::KeyCode; diff --git a/vortex-tui/src/browse/mod.rs b/vortex-tui/src/browse/mod.rs index a18a6dfcfda..065b96e637f 100644 --- a/vortex-tui/src/browse/mod.rs +++ b/vortex-tui/src/browse/mod.rs @@ -59,7 +59,7 @@ fn navigate_layout_down(app: &mut AppState, amount: usize) { #[allow(clippy::cognitive_complexity)] pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> HandleResult { // Check if we're in Query tab with SQL input focus - handle text input first - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] { use ui::QueryFocus; use ui::SortDirection; @@ -109,23 +109,23 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl (InputKeyCode::Tab, ..) => { app.current_tab = match app.current_tab { Tab::Layout => Tab::Segments, - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Segments => Tab::Query, - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => Tab::Layout, - #[cfg(target_arch = "wasm32")] + #[cfg(not(feature = "native"))] Tab::Segments => Tab::Layout, }; } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] (InputKeyCode::Char('['), false, false, _) => { if app.current_tab == Tab::Query { app.query_state.prepare_prev_page(); } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] (InputKeyCode::Char(']'), false, false, _) => { if app.current_tab == Tab::Query { app.query_state.prepare_next_page(); @@ -137,7 +137,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl | (InputKeyCode::Char('p'), true, ..) => match app.current_tab { Tab::Layout => navigate_layout_up(app, SCROLL_LINE), Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_LINE), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.table_state.select_previous(); } @@ -147,7 +147,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl | (InputKeyCode::Char('n'), true, ..) => match app.current_tab { Tab::Layout => navigate_layout_down(app, SCROLL_LINE), Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_LINE), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.table_state.select_next(); } @@ -156,7 +156,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl match app.current_tab { Tab::Layout => navigate_layout_up(app, SCROLL_PAGE), Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.prepare_prev_page(); } @@ -166,7 +166,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl match app.current_tab { Tab::Layout => navigate_layout_down(app, SCROLL_PAGE), Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.prepare_next_page(); } @@ -177,7 +177,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app .segment_grid_state .scroll_left(SEGMENT_SCROLL_HORIZONTAL_JUMP), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.table_state.select_first(); } @@ -187,7 +187,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app .segment_grid_state .scroll_right(SEGMENT_SCROLL_HORIZONTAL_JUMP), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.table_state.select_last(); } @@ -209,7 +209,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app .segment_grid_state .scroll_left(SEGMENT_SCROLL_HORIZONTAL_STEP), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { app.query_state.horizontal_scroll = app.query_state.horizontal_scroll.saturating_sub(1); @@ -222,7 +222,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl Tab::Segments => app .segment_grid_state .scroll_right(SEGMENT_SCROLL_HORIZONTAL_STEP), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => { let max_col = app.query_state.column_count().saturating_sub(1); if app.query_state.horizontal_scroll < max_col { @@ -232,19 +232,19 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl }, (InputKeyCode::Char('/'), ..) | (InputKeyCode::Char('s'), true, ..) => { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] if app.current_tab == Tab::Query { // Don't enter search mode from query tab } else { app.key_mode = KeyMode::Search; } - #[cfg(target_arch = "wasm32")] + #[cfg(not(feature = "native"))] { app.key_mode = KeyMode::Search; } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] (InputKeyCode::Char('s'), false, false, _) => { if app.current_tab == Tab::Query { let col = app.query_state.selected_column(); @@ -252,7 +252,7 @@ pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> Handl } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] (InputKeyCode::Esc, ..) => { if app.current_tab == Tab::Query { app.query_state.toggle_focus(); @@ -346,7 +346,7 @@ pub(crate) fn handle_search_mode(app: &mut AppState, event: InputEvent) -> Handl // --- Native-only crossterm event loop --- -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] mod native { use crossterm::event::Event; use crossterm::event::EventStream; @@ -445,5 +445,5 @@ mod native { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use native::exec_tui; diff --git a/vortex-tui/src/browse/ui/mod.rs b/vortex-tui/src/browse/ui/mod.rs index 9f5ca7c4894..4947b167552 100644 --- a/vortex-tui/src/browse/ui/mod.rs +++ b/vortex-tui/src/browse/ui/mod.rs @@ -4,18 +4,18 @@ //! UI rendering components for the TUI browser. mod layouts; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] mod query; mod segments; use layouts::render_layouts; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use query::QueryFocus; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use query::QueryState; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use query::SortDirection; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] use query::render_query; use ratatui::prelude::*; use ratatui::widgets::Block; @@ -72,7 +72,7 @@ pub fn render_app(app: &mut AppState, frame: &mut Frame<'_>) { .areas(inner_area); // Display a tab indicator. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] let (selected_tab, tab_names) = { let selected = match app.current_tab { Tab::Layout => 0, @@ -82,7 +82,7 @@ pub fn render_app(app: &mut AppState, frame: &mut Frame<'_>) { (selected, vec!["File Layout", "Segments", "Query"]) }; - #[cfg(target_arch = "wasm32")] + #[cfg(not(feature = "native"))] let (selected_tab, tab_names) = { let selected = match app.current_tab { Tab::Layout => 0, @@ -109,7 +109,7 @@ pub fn render_app(app: &mut AppState, frame: &mut Frame<'_>) { render_layouts(app, app_view, frame.buffer_mut()); } Tab::Segments => segments_ui(app, app_view, frame.buffer_mut()), - #[cfg(not(target_arch = "wasm32"))] + #[cfg(feature = "native")] Tab::Query => render_query(app, app_view, frame.buffer_mut()), } } diff --git a/vortex-tui/src/lib.rs b/vortex-tui/src/lib.rs index 2c47cbd47ec..7a9aab7191a 100644 --- a/vortex-tui/src/lib.rs +++ b/vortex-tui/src/lib.rs @@ -4,7 +4,7 @@ //! Vortex TUI library for interactively browsing and inspecting Vortex files. //! //! This crate provides both a CLI tool (`vx`) and a library API for working with Vortex files. -//! Users can bring their own [`vortex::VortexSession`] to enable custom encodings and extensions. +//! Users can bring their own `VortexSession` to enable custom encodings and extensions. //! //! # Example //! @@ -26,26 +26,30 @@ #![deny(clippy::missing_safety_doc)] #![deny(missing_docs)] +#[cfg_attr( + all(not(feature = "native"), not(target_arch = "wasm32")), + allow(dead_code, unused_imports) +)] pub mod browse; pub mod segment_tree; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod convert; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod datafusion_helper; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod inspect; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod query; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod segments; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub mod tree; #[cfg(target_arch = "wasm32")] pub mod wasm; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] mod native_cli { use std::ffi::OsString; use std::path::PathBuf; @@ -147,7 +151,7 @@ mod native_cli { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use native_cli::launch; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "native")] pub use native_cli::launch_from; From 67530ad702c404e3ab30dec9012bb826a4003340 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 23:13:04 -0500 Subject: [PATCH 07/11] mergE Signed-off-by: Nicholas Gates --- Cargo.toml | 8 +++++++- vortex-tui/Cargo.toml | 14 +++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b5490594dd7..b31a0f8cda7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,7 @@ cbindgen = "0.29.0" cc = "1.2" cfg-if = "1.0.1" chrono = "0.4.42" +console_error_panic_hook = "0.1.7" clap = "4.5" criterion = "0.7" crossterm = "0.29" @@ -136,6 +137,7 @@ datafusion-pruning = { version = "52" } datafusion-sqllogictest = { version = "52" } dirs = "6.0.0" divan = { package = "codspeed-divan-compat", version = "4.0.4" } +env_logger = "0.11" enum-iterator = "2.0.0" fastlanes = "0.5" flatbuffers = "25.2.10" @@ -153,6 +155,7 @@ insta = "1.43" inventory = "0.3.20" itertools = "0.14.0" jiff = "0.2.0" +js-sys = "0.3" kanal = "0.1.1" lending-iterator = "0.1.7" libfuzzer-sys = "0.4" @@ -191,7 +194,8 @@ pyo3-log = "0.13.0" quote = "1.0.41" rand = "0.9.0" rand_distr = "0.5" -ratatui = "0.30" +ratatui = { version = "0.30", default-features = false } +ratzilla = "0.3" regex = "1.11.0" reqwest = { version = "0.12.4", features = [ "charset", @@ -235,7 +239,9 @@ tracing-subscriber = "0.3" url = "2.5.7" uuid = { version = "1.19", features = ["js"] } walkdir = "2.5.0" +wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4.39" +web-sys = "0.3.81" xshell = "0.2.6" zigzag = "0.1.0" zip = "6.0.0" diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 5cd75136086..d219ff7d59b 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -47,7 +47,7 @@ futures = { workspace = true } fuzzy-matcher = { workspace = true } humansize = { workspace = true } itertools = { workspace = true } -ratatui = { version = "0.30", default-features = false } +ratatui = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } taffy = { workspace = true } @@ -61,7 +61,7 @@ arrow-schema = { workspace = true, optional = true } clap = { workspace = true, features = ["derive"], optional = true } crossterm = { workspace = true, features = ["event-stream"], optional = true } datafusion = { workspace = true, optional = true } -env_logger = { version = "0.11", optional = true } +env_logger = { workspace = true, optional = true } indicatif = { workspace = true, features = ["futures"], optional = true } parquet = { workspace = true, features = ["arrow", "async"], optional = true } tokio = { workspace = true, features = [ @@ -72,12 +72,12 @@ vortex-datafusion = { workspace = true, optional = true } # WASM-only dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -console_error_panic_hook = "0.1.7" -js-sys = "0.3" -ratzilla = "0.3" -wasm-bindgen = "0.2" +console_error_panic_hook = { workspace = true } +js-sys = { workspace = true } +ratzilla = { workspace = true } +wasm-bindgen = { workspace = true } wasm-bindgen-futures = { workspace = true } -web-sys = { version = "0.3.81", features = [ +web-sys = { workspace = true, features = [ "console", "DataTransfer", "Document", From 09a63ab24b905e4e88d3ac2309995c959c7a384c Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 23:14:19 -0500 Subject: [PATCH 08/11] mergE Signed-off-by: Nicholas Gates --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b31a0f8cda7..06923747f89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,8 +109,8 @@ cbindgen = "0.29.0" cc = "1.2" cfg-if = "1.0.1" chrono = "0.4.42" -console_error_panic_hook = "0.1.7" clap = "4.5" +console_error_panic_hook = "0.1.7" criterion = "0.7" crossterm = "0.29" cudarc = { version = "0.18.2", features = [ @@ -137,8 +137,8 @@ datafusion-pruning = { version = "52" } datafusion-sqllogictest = { version = "52" } dirs = "6.0.0" divan = { package = "codspeed-divan-compat", version = "4.0.4" } -env_logger = "0.11" enum-iterator = "2.0.0" +env_logger = "0.11" fastlanes = "0.5" flatbuffers = "25.2.10" fsst-rs = "0.5.5" From b869fda78ffc3b09f6c3b208df83695174506935 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 23:19:43 -0500 Subject: [PATCH 09/11] mergE Signed-off-by: Nicholas Gates --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 06923747f89..e15d7007f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,7 +155,7 @@ insta = "1.43" inventory = "0.3.20" itertools = "0.14.0" jiff = "0.2.0" -js-sys = "0.3" +js-sys = "0.3.81" kanal = "0.1.1" lending-iterator = "0.1.7" libfuzzer-sys = "0.4" From 3ef011e53e75a60633e5a40648d4cdf618a40cd9 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Tue, 3 Mar 2026 23:25:08 -0500 Subject: [PATCH 10/11] mergE Signed-off-by: Nicholas Gates --- Cargo.toml | 5 ----- vortex-tui/Cargo.toml | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e15d7007f50..c6fdef93701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,7 +110,6 @@ cc = "1.2" cfg-if = "1.0.1" chrono = "0.4.42" clap = "4.5" -console_error_panic_hook = "0.1.7" criterion = "0.7" crossterm = "0.29" cudarc = { version = "0.18.2", features = [ @@ -155,7 +154,6 @@ insta = "1.43" inventory = "0.3.20" itertools = "0.14.0" jiff = "0.2.0" -js-sys = "0.3.81" kanal = "0.1.1" lending-iterator = "0.1.7" libfuzzer-sys = "0.4" @@ -195,7 +193,6 @@ quote = "1.0.41" rand = "0.9.0" rand_distr = "0.5" ratatui = { version = "0.30", default-features = false } -ratzilla = "0.3" regex = "1.11.0" reqwest = { version = "0.12.4", features = [ "charset", @@ -239,9 +236,7 @@ tracing-subscriber = "0.3" url = "2.5.7" uuid = { version = "1.19", features = ["js"] } walkdir = "2.5.0" -wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4.39" -web-sys = "0.3.81" xshell = "0.2.6" zigzag = "0.1.0" zip = "6.0.0" diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index d219ff7d59b..6c7d1e7046e 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -72,12 +72,12 @@ vortex-datafusion = { workspace = true, optional = true } # WASM-only dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] -console_error_panic_hook = { workspace = true } -js-sys = { workspace = true } -ratzilla = { workspace = true } -wasm-bindgen = { workspace = true } +console_error_panic_hook = "0.1.7" +js-sys = "0.3.81" +ratzilla = "0.3" +wasm-bindgen = "0.2" wasm-bindgen-futures = { workspace = true } -web-sys = { workspace = true, features = [ +web-sys = { version = "0.3.81", features = [ "console", "DataTransfer", "Document", From ea68b778c2076a8bd8ee47e11f7e6cddde0af7a0 Mon Sep 17 00:00:00 2001 From: Nicholas Gates Date: Wed, 4 Mar 2026 00:11:18 -0500 Subject: [PATCH 11/11] mergE Signed-off-by: Nicholas Gates --- Cargo.toml | 2 +- vortex-tui/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c6fdef93701..5bbc2ebab02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -236,7 +236,7 @@ tracing-subscriber = "0.3" url = "2.5.7" uuid = { version = "1.19", features = ["js"] } walkdir = "2.5.0" -wasm-bindgen-futures = "0.4.39" +wasm-bindgen-futures = "0.4.54" xshell = "0.2.6" zigzag = "0.1.0" zip = "6.0.0" diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 6c7d1e7046e..ad4d0b052f2 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -75,7 +75,7 @@ vortex-datafusion = { workspace = true, optional = true } console_error_panic_hook = "0.1.7" js-sys = "0.3.81" ratzilla = "0.3" -wasm-bindgen = "0.2" +wasm-bindgen = "0.2.104" wasm-bindgen-futures = { workspace = true } web-sys = { version = "0.3.81", features = [ "console",