diff --git a/.vscode/settings.json b/.vscode/settings.json index f3c08cc8a76a..2645e3d2cfdb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -65,4 +65,13 @@ "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "workbench.colorCustomizations": { + "sash.hoverBorder": "#fa1b49", + "statusBar.background": "#dd0531", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#fa1b49", + "statusBarItem.remoteBackground": "#dd0531", + "statusBarItem.remoteForeground": "#e7e7e7" + }, + "peacock.color": "#dd0531", } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 326cba7e2779..23867f59cef1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,6 +158,7 @@ Update instructions: | re_view_text_document | A simple View that shows a single text box. | | re_view_text_log | A View that shows text entries in a table and scrolls with the active time. | | re_view_time_series | A View that shows plots over Rerun timelines. | +| re_view_web_page | A native-only experimental View that embeds a configured web page. | | re_viewer | The Rerun Viewer | | re_viewport | The central viewport panel of the Rerun viewer. | diff --git a/Cargo.lock b/Cargo.lock index d049c42e43aa..627df2dafd1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,8 +133,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -863,6 +863,29 @@ dependencies = [ "loom", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atoi" version = "2.0.0" @@ -1024,7 +1047,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1103,15 +1126,30 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -1390,6 +1428,31 @@ dependencies = [ "walkdir", ] +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "calloop" version = "0.13.0" @@ -1516,6 +1579,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1548,7 +1621,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1631,7 +1704,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2122,6 +2195,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "csv" version = "1.3.1" @@ -3011,6 +3107,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "devserver_lib" version = "0.4.2" @@ -3049,11 +3166,15 @@ dependencies = [ "clap", "futures-util", "mimalloc", + "objc2 0.6.4", + "parking_lot", + "re_log", "rerun", "serde", "serde_json", "tokio", "tokio-tungstenite", + "url", ] [[package]] @@ -3142,6 +3263,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -3166,6 +3302,21 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" version = "0.34.0" @@ -3384,7 +3535,7 @@ dependencies = [ "pollster", "serde", "tempfile", - "toml", + "toml 1.0.6+spec-1.1.0", "wgpu", ] @@ -3722,6 +3873,16 @@ dependencies = [ "anyhow", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.3" @@ -4010,6 +4171,91 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generational-arena" version = "0.2.9" @@ -4030,8 +4276,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link", - "windows-result", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -4125,6 +4371,38 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -4146,6 +4424,53 @@ dependencies = [ "serde_core", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glob" version = "0.3.3" @@ -4282,6 +4607,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -4293,7 +4629,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows", + "windows 0.62.2", ] [[package]] @@ -4326,6 +4662,58 @@ dependencies = [ "rerun", ] +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "h2" version = "0.4.12" @@ -4401,6 +4789,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -4470,6 +4864,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -4681,7 +5085,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -5027,6 +5431,29 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "jiff" version = "0.2.23" @@ -5786,7 +6213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6034,10 +6461,21 @@ dependencies = [ ] [[package]] -name = "matchers" -version = "0.2.0" +name = "markup5ever" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ "regex-automata", ] @@ -6331,7 +6769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.11.0", "cfg-if", "cfg_aliases", @@ -6416,6 +6854,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.30.1" @@ -6590,7 +7034,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6635,6 +7079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -6757,6 +7202,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.2.2" @@ -6932,6 +7386,20 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "object" version = "0.37.3" @@ -7167,6 +7635,31 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -7195,7 +7688,7 @@ dependencies = [ "petgraph 0.6.5", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7406,6 +7899,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -7672,6 +8175,12 @@ dependencies = [ "zerocopy 0.8.27", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -7688,13 +8197,57 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.6", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -7765,7 +8318,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -7886,7 +8439,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" dependencies = [ - "target-lexicon", + "target-lexicon 0.13.3", ] [[package]] @@ -7917,7 +8470,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", @@ -8728,7 +9281,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "toml", + "toml 1.0.6+spec-1.1.0", "ureq 3.3.0", "url", "wasm-bindgen-cli-support", @@ -9496,7 +10049,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -9924,7 +10477,7 @@ dependencies = [ "serde", "syn 2.0.117", "tempfile", - "toml", + "toml 1.0.6+spec-1.1.0", "unindent", "xshell", ] @@ -10015,7 +10568,7 @@ name = "re_video" version = "0.32.0-alpha.1" dependencies = [ "ahash", - "bit-vec", + "bit-vec 0.9.1", "bytemuck", "cfg_aliases", "criterion", @@ -10346,6 +10899,30 @@ dependencies = [ "vec1", ] +[[package]] +name = "re_view_web_page" +version = "0.32.0-alpha.1" +dependencies = [ + "ahash", + "eframe", + "egui", + "egui_kittest", + "gtk", + "parking_lot", + "raw-window-handle", + "re_log_types", + "re_sdk_types", + "re_test_context", + "re_test_viewport", + "re_tracing", + "re_ui", + "re_viewer_context", + "re_viewport_blueprint", + "scoped-tls", + "url", + "wry", +] + [[package]] name = "re_viewer" version = "0.32.0-alpha.1" @@ -10428,6 +11005,7 @@ dependencies = [ "re_view_text_document", "re_view_text_log", "re_view_time_series", + "re_view_web_page", "re_viewer_context", "re_viewport", "re_viewport_blueprint", @@ -10660,7 +11238,7 @@ dependencies = [ "cfg-if", "libc", "rustix 1.1.4", - "windows", + "windows 0.62.2", ] [[package]] @@ -11275,6 +11853,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -11395,6 +11992,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -11416,6 +12022,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -11646,7 +12261,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54254b8531cafa275c5e096f62d48c81435d1015405a91198ddb11e967301d40" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -11692,6 +12307,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "spawn_viewer" version = "0.32.0-alpha.1" @@ -11803,6 +12444,30 @@ dependencies = [ "float-cmp 0.9.0", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -11824,7 +12489,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -11906,7 +12571,20 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.62.2", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", ] [[package]] @@ -12061,12 +12739,29 @@ dependencies = [ "serde", ] +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "target-lexicon" version = "0.13.3" @@ -12093,6 +12788,16 @@ dependencies = [ "rerun", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -12435,6 +13140,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "toml" version = "1.0.6+spec-1.1.0" @@ -12443,11 +13160,20 @@ checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" dependencies = [ "indexmap", "serde_core", - "serde_spanned", + "serde_spanned 1.0.4", "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", ] [[package]] @@ -12468,6 +13194,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.23.6" @@ -12477,7 +13227,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -12486,7 +13236,7 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -12779,7 +13529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" dependencies = [ "cc", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -13119,6 +13869,12 @@ dependencies = [ "vello_common", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -13189,7 +13945,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9b0525d7ea6e5f906aca581a172e5c91b4c595290dfa8ad4a2bc9ffef33b44" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -13500,6 +14256,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webbrowser" version = "1.1.0" @@ -13516,6 +14284,50 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -13534,6 +14346,42 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + [[package]] name = "weezl" version = "0.1.10" @@ -13577,8 +14425,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.11.0", "bytemuck", "cfg_aliases", @@ -13649,7 +14497,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.11.0", "block2 0.6.2", "bytemuck", @@ -13689,8 +14537,8 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -13754,16 +14602,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -13772,7 +14642,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -13783,9 +14666,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -13794,9 +14688,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -13821,20 +14715,45 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -13843,7 +14762,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -13852,7 +14780,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -13897,7 +14825,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -13952,7 +14880,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -13963,13 +14891,31 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -14203,6 +15149,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.13" @@ -14234,7 +15189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -14245,7 +15200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", @@ -14312,6 +15267,50 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + [[package]] name = "wyz" version = "0.5.1" @@ -14321,6 +15320,16 @@ dependencies = [ "tap", ] +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -14462,7 +15471,7 @@ dependencies = [ "tracing", "uds_windows", "windows-sys 0.60.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", @@ -14498,7 +15507,7 @@ version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.117", @@ -14515,7 +15524,7 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow", + "winnow 0.7.13", "zvariant", ] @@ -14735,7 +15744,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.13", "zvariant_derive", "zvariant_utils", ] @@ -14746,7 +15755,7 @@ version = "5.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.117", @@ -14763,5 +15772,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.13", ] diff --git a/Cargo.toml b/Cargo.toml index 6e7969a460e3..e8a17eff4a01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,7 @@ re_view_tensor = { path = "crates/viewer/re_view_tensor", version = "=0.32.0-alp re_view_text_document = { path = "crates/viewer/re_view_text_document", version = "=0.32.0-alpha.1", default-features = false } re_view_text_log = { path = "crates/viewer/re_view_text_log", version = "=0.32.0-alpha.1", default-features = false } re_view_time_series = { path = "crates/viewer/re_view_time_series", version = "=0.32.0-alpha.1", default-features = false } +re_view_web_page = { path = "crates/viewer/re_view_web_page", version = "=0.32.0-alpha.1", default-features = false } re_viewer = { path = "crates/viewer/re_viewer", version = "=0.32.0-alpha.1", default-features = false } re_viewer_context = { path = "crates/viewer/re_viewer_context", version = "=0.32.0-alpha.1", default-features = false } re_viewport = { path = "crates/viewer/re_viewport", version = "=0.32.0-alpha.1", default-features = false } @@ -360,6 +361,7 @@ roxmltree = "0.20.0" ring = "0.17.14" rustls = { version = "0.23.37", default-features = false } saturating_cast = "0.1" +scoped-tls = "1.0" scuffle-av1 = "0.1.4" scuffle-bytes-util = "0.1.5" semver = "1.0.27" @@ -439,7 +441,9 @@ webbrowser = "1.1" windows-core = { version = "0.62", default-features = false, features = [ "std", ] } # Ensure `std` is enabled so `windows-result::Error` impls `core::error::Error` (needed by wgpu-hal gles on Windows) +gtk = "0.18.2" winit = { version = "0.30.13", default-features = false } +wry = { version = "0.55.1", default-features = false } # TODO(andreas): Try to get rid of `fragile-send-sync-non-atomic-wasm`. This requires re_renderer being aware of single-thread restriction on resources. # See also https://gpuweb.github.io/gpuweb/explainer/#multithreading-transfer (unsolved part of the Spec as of writing!) wgpu = { version = "29.0.1", default-features = false, features = [ diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes.fbs index 63519067e802..c3d5741e8dca 100644 --- a/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes.fbs +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes.fbs @@ -32,3 +32,4 @@ include "./archetypes/viewport_blueprint.fbs"; include "./archetypes/visible_time_ranges.fbs"; include "./archetypes/visual_bounds2d.fbs"; include "./archetypes/visualizer_instruction.fbs"; +include "./archetypes/web_page_view_config.fbs"; diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs new file mode 100644 index 000000000000..12e6a16ff5f2 --- /dev/null +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs @@ -0,0 +1,19 @@ +namespace rerun.blueprint.archetypes; + +/// Configuration for the Web Page View. +table WebPageViewConfig ( + "attr.docs.view_types": "WebPageView", + "attr.rerun.scope": "blueprint" +) { + // --- Required --- + + /// The initial URL to load. + url: rerun.blueprint.components.WebPageUrl ("attr.rerun.component_required", order: 100); + + // --- Optional --- + + /// Whether browser navigation controls should be shown. + /// + /// Defaults to true. + show_navigation_controls: rerun.blueprint.components.ShowNavigationControls ("attr.rerun.component_optional", nullable, order: 200); +} diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/components.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/components.fbs index 96b173a9a360..d78307f5cff3 100644 --- a/crates/store/re_sdk_types/definitions/rerun/blueprint/components.fbs +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/components.fbs @@ -36,6 +36,7 @@ include "./components/query_expression.fbs"; include "./components/root_container.fbs"; include "./components/row_share.fbs"; include "./components/selected_columns.fbs"; +include "./components/show_navigation_controls.fbs"; include "./components/tensor_dimension_index_slider.fbs"; include "./components/text_log_column.fbs"; include "./components/time_int.fbs"; @@ -52,4 +53,5 @@ include "./components/visual_bounds2d.fbs"; include "./components/visualizer_component_mapping.fbs"; include "./components/visualizer_instruction_id.fbs"; include "./components/visualizer_type.fbs"; +include "./components/web_page_url.fbs"; include "./components/zoom_level.fbs"; diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs new file mode 100644 index 000000000000..38ea5bd4e84b --- /dev/null +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs @@ -0,0 +1,14 @@ + +namespace rerun.blueprint.components; + +/// Whether browser navigation controls should be shown in the Web Page View. +struct ShowNavigationControls ( + "attr.arrow.transparent", + "attr.rerun.scope": "blueprint", + "attr.python.aliases": "bool", + "attr.rust.derive": "Copy, Default, PartialEq, Eq, PartialOrd, Ord", + "attr.rust.repr": "transparent", + "attr.rust.tuple_struct" +) { + show_navigation_controls: rerun.datatypes.Bool (order: 100); +} diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs new file mode 100644 index 000000000000..0fa8ba38d596 --- /dev/null +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs @@ -0,0 +1,13 @@ + +namespace rerun.blueprint.components; + +/// The initial URL to load in a Web Page View. +table WebPageUrl ( + "attr.arrow.transparent", + "attr.rerun.scope": "blueprint", + "attr.python.aliases": "str", + "attr.rust.derive": "Default, PartialEq, Eq, PartialOrd, Ord", + "attr.rust.repr": "transparent" +) { + url: rerun.datatypes.Utf8 (order: 100); +} diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/views.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/views.fbs index c2196abd7e23..5dfcbf7c3dbe 100644 --- a/crates/store/re_sdk_types/definitions/rerun/blueprint/views.fbs +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/views.fbs @@ -11,3 +11,4 @@ include "./views/tensor.fbs"; include "./views/text_document.fbs"; include "./views/text_log.fbs"; include "./views/time_series.fbs"; +include "./views/web_page.fbs"; diff --git a/crates/store/re_sdk_types/definitions/rerun/blueprint/views/web_page.fbs b/crates/store/re_sdk_types/definitions/rerun/blueprint/views/web_page.fbs new file mode 100644 index 000000000000..669f66dc0155 --- /dev/null +++ b/crates/store/re_sdk_types/definitions/rerun/blueprint/views/web_page.fbs @@ -0,0 +1,17 @@ +namespace rerun.blueprint.views; + +/// A native-only view that embeds a configured web page. +/// +/// Web Page View is unsupported in the web viewer. Native support depends on the +/// operating-system webview runtime provided through `wry`: `WebView2` on Windows, +/// `WebKit` on macOS, and `WebKitGTK` on Linux. Linux child webviews may also depend +/// on the active display-server integration; the direct child-window path is +/// X11-oriented, while Wayland-capable embedding requires a GTK container path. +table WebPageView ( + "attr.docs.unreleased", + "attr.rerun.view_identifier": "WebPage", + "attr.rerun.state": "unstable" +) { + /// Configuration for the web page to embed. + config: rerun.blueprint.archetypes.WebPageViewConfig (order: 1000); +} diff --git a/crates/store/re_sdk_types/src/blueprint/archetypes/.gitattributes b/crates/store/re_sdk_types/src/blueprint/archetypes/.gitattributes index 4d0325f9d507..e963b31a8127 100644 --- a/crates/store/re_sdk_types/src/blueprint/archetypes/.gitattributes +++ b/crates/store/re_sdk_types/src/blueprint/archetypes/.gitattributes @@ -37,3 +37,4 @@ viewport_blueprint.rs linguist-generated=true visible_time_ranges.rs linguist-generated=true visual_bounds2d.rs linguist-generated=true visualizer_instruction.rs linguist-generated=true +web_page_view_config.rs linguist-generated=true diff --git a/crates/store/re_sdk_types/src/blueprint/archetypes/mod.rs b/crates/store/re_sdk_types/src/blueprint/archetypes/mod.rs index c683ae55c04b..1581b110a648 100644 --- a/crates/store/re_sdk_types/src/blueprint/archetypes/mod.rs +++ b/crates/store/re_sdk_types/src/blueprint/archetypes/mod.rs @@ -36,6 +36,7 @@ mod viewport_blueprint; mod visible_time_ranges; mod visual_bounds2d; mod visualizer_instruction; +mod web_page_view_config; pub use self::active_visualizers::ActiveVisualizers; pub use self::background::Background; @@ -72,3 +73,4 @@ pub use self::viewport_blueprint::ViewportBlueprint; pub use self::visible_time_ranges::VisibleTimeRanges; pub use self::visual_bounds2d::VisualBounds2D; pub use self::visualizer_instruction::VisualizerInstruction; +pub use self::web_page_view_config::WebPageViewConfig; diff --git a/crates/store/re_sdk_types/src/blueprint/archetypes/web_page_view_config.rs b/crates/store/re_sdk_types/src/blueprint/archetypes/web_page_view_config.rs new file mode 100644 index 000000000000..ca4cacbfaff3 --- /dev/null +++ b/crates/store/re_sdk_types/src/blueprint/archetypes/web_page_view_config.rs @@ -0,0 +1,215 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/rust/api.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs". + +#![allow(unused_braces)] +#![allow(unused_imports)] +#![allow(unused_parens)] +#![allow(clippy::allow_attributes)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::cloned_instead_of_copied)] +#![allow(clippy::map_flatten)] +#![allow(clippy::needless_question_mark)] +#![allow(clippy::new_without_default)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::wildcard_imports)] + +use ::re_types_core::SerializationResult; +use ::re_types_core::try_serialize_field; +use ::re_types_core::{ComponentBatch as _, SerializedComponentBatch}; +use ::re_types_core::{ComponentDescriptor, ComponentType}; +use ::re_types_core::{DeserializationError, DeserializationResult}; + +/// **Archetype**: Configuration for the Web Page View. +/// +/// ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** +#[derive(Clone, Debug, Default)] +pub struct WebPageViewConfig { + /// The initial URL to load. + pub url: Option, + + /// Whether browser navigation controls should be shown. + /// + /// Defaults to true. + pub show_navigation_controls: Option, +} + +impl WebPageViewConfig { + /// Returns the [`ComponentDescriptor`] for [`Self::url`]. + /// + /// The corresponding component is [`crate::blueprint::components::WebPageUrl`]. + #[inline] + pub fn descriptor_url() -> ComponentDescriptor { + ComponentDescriptor { + archetype: Some("rerun.blueprint.archetypes.WebPageViewConfig".into()), + component: "WebPageViewConfig:url".into(), + component_type: Some("rerun.blueprint.components.WebPageUrl".into()), + } + } + + /// Returns the [`ComponentDescriptor`] for [`Self::show_navigation_controls`]. + /// + /// The corresponding component is [`crate::blueprint::components::ShowNavigationControls`]. + #[inline] + pub fn descriptor_show_navigation_controls() -> ComponentDescriptor { + ComponentDescriptor { + archetype: Some("rerun.blueprint.archetypes.WebPageViewConfig".into()), + component: "WebPageViewConfig:show_navigation_controls".into(), + component_type: Some("rerun.blueprint.components.ShowNavigationControls".into()), + } + } +} + +static REQUIRED_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 1usize]> = + std::sync::LazyLock::new(|| [WebPageViewConfig::descriptor_url()]); + +static RECOMMENDED_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 0usize]> = + std::sync::LazyLock::new(|| []); + +static OPTIONAL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 1usize]> = + std::sync::LazyLock::new(|| [WebPageViewConfig::descriptor_show_navigation_controls()]); + +static ALL_COMPONENTS: std::sync::LazyLock<[ComponentDescriptor; 2usize]> = + std::sync::LazyLock::new(|| { + [ + WebPageViewConfig::descriptor_url(), + WebPageViewConfig::descriptor_show_navigation_controls(), + ] + }); + +impl WebPageViewConfig { + /// The total number of components in the archetype: 1 required, 0 recommended, 1 optional + pub const NUM_COMPONENTS: usize = 2usize; +} + +impl ::re_types_core::Archetype for WebPageViewConfig { + #[inline] + fn name() -> ::re_types_core::ArchetypeName { + "rerun.blueprint.archetypes.WebPageViewConfig".into() + } + + #[inline] + fn display_name() -> &'static str { + "Web page view config" + } + + #[inline] + fn required_components() -> ::std::borrow::Cow<'static, [ComponentDescriptor]> { + REQUIRED_COMPONENTS.as_slice().into() + } + + #[inline] + fn recommended_components() -> ::std::borrow::Cow<'static, [ComponentDescriptor]> { + RECOMMENDED_COMPONENTS.as_slice().into() + } + + #[inline] + fn optional_components() -> ::std::borrow::Cow<'static, [ComponentDescriptor]> { + OPTIONAL_COMPONENTS.as_slice().into() + } + + #[inline] + fn all_components() -> ::std::borrow::Cow<'static, [ComponentDescriptor]> { + ALL_COMPONENTS.as_slice().into() + } + + #[inline] + fn from_arrow_components( + arrow_data: impl IntoIterator, + ) -> DeserializationResult { + re_tracing::profile_function!(); + use ::re_types_core::{Loggable as _, ResultExt as _}; + let arrays_by_descr: ::nohash_hasher::IntMap<_, _> = arrow_data.into_iter().collect(); + let url = arrays_by_descr + .get(&Self::descriptor_url()) + .map(|array| SerializedComponentBatch::new(array.clone(), Self::descriptor_url())); + let show_navigation_controls = arrays_by_descr + .get(&Self::descriptor_show_navigation_controls()) + .map(|array| { + SerializedComponentBatch::new( + array.clone(), + Self::descriptor_show_navigation_controls(), + ) + }); + Ok(Self { + url, + show_navigation_controls, + }) + } +} + +impl ::re_types_core::AsComponents for WebPageViewConfig { + #[inline] + fn as_serialized_batches(&self) -> Vec { + use ::re_types_core::Archetype as _; + [self.url.clone(), self.show_navigation_controls.clone()] + .into_iter() + .flatten() + .collect() + } +} + +impl ::re_types_core::ArchetypeReflectionMarker for WebPageViewConfig {} + +impl WebPageViewConfig { + /// Create a new `WebPageViewConfig`. + #[inline] + pub fn new(url: impl Into) -> Self { + Self { + url: try_serialize_field(Self::descriptor_url(), [url]), + show_navigation_controls: None, + } + } + + /// Update only some specific fields of a `WebPageViewConfig`. + #[inline] + pub fn update_fields() -> Self { + Self::default() + } + + /// Clear all the fields of a `WebPageViewConfig`. + #[inline] + pub fn clear_fields() -> Self { + use ::re_types_core::Loggable as _; + Self { + url: Some(SerializedComponentBatch::new( + crate::blueprint::components::WebPageUrl::arrow_empty(), + Self::descriptor_url(), + )), + show_navigation_controls: Some(SerializedComponentBatch::new( + crate::blueprint::components::ShowNavigationControls::arrow_empty(), + Self::descriptor_show_navigation_controls(), + )), + } + } + + /// The initial URL to load. + #[inline] + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = try_serialize_field(Self::descriptor_url(), [url]); + self + } + + /// Whether browser navigation controls should be shown. + /// + /// Defaults to true. + #[inline] + pub fn with_show_navigation_controls( + mut self, + show_navigation_controls: impl Into, + ) -> Self { + self.show_navigation_controls = try_serialize_field( + Self::descriptor_show_navigation_controls(), + [show_navigation_controls], + ); + self + } +} + +impl ::re_byte_size::SizeBytes for WebPageViewConfig { + #[inline] + fn heap_size_bytes(&self) -> u64 { + self.url.heap_size_bytes() + self.show_navigation_controls.heap_size_bytes() + } +} diff --git a/crates/store/re_sdk_types/src/blueprint/components/.gitattributes b/crates/store/re_sdk_types/src/blueprint/components/.gitattributes index ade60b56d169..a3f5fdd699e2 100644 --- a/crates/store/re_sdk_types/src/blueprint/components/.gitattributes +++ b/crates/store/re_sdk_types/src/blueprint/components/.gitattributes @@ -38,6 +38,7 @@ query_expression.rs linguist-generated=true root_container.rs linguist-generated=true row_share.rs linguist-generated=true selected_columns.rs linguist-generated=true +show_navigation_controls.rs linguist-generated=true tensor_dimension_index_slider.rs linguist-generated=true text_log_column.rs linguist-generated=true time_int.rs linguist-generated=true @@ -54,4 +55,5 @@ visual_bounds2d.rs linguist-generated=true visualizer_component_mapping.rs linguist-generated=true visualizer_instruction_id.rs linguist-generated=true visualizer_type.rs linguist-generated=true +web_page_url.rs linguist-generated=true zoom_level.rs linguist-generated=true diff --git a/crates/store/re_sdk_types/src/blueprint/components/mod.rs b/crates/store/re_sdk_types/src/blueprint/components/mod.rs index fea4631c39da..6165a9e4a3bc 100644 --- a/crates/store/re_sdk_types/src/blueprint/components/mod.rs +++ b/crates/store/re_sdk_types/src/blueprint/components/mod.rs @@ -45,6 +45,7 @@ mod query_expression; mod root_container; mod row_share; mod selected_columns; +mod show_navigation_controls; mod tensor_dimension_index_slider; mod tensor_dimension_index_slider_ext; mod text_log_column; @@ -68,6 +69,7 @@ mod visualizer_component_mapping; mod visualizer_instruction_id; mod visualizer_instruction_id_ext; mod visualizer_type; +mod web_page_url; mod zoom_level; pub use self::absolute_time_range::AbsoluteTimeRange; @@ -106,6 +108,7 @@ pub use self::query_expression::QueryExpression; pub use self::root_container::RootContainer; pub use self::row_share::RowShare; pub use self::selected_columns::SelectedColumns; +pub use self::show_navigation_controls::ShowNavigationControls; pub use self::tensor_dimension_index_slider::TensorDimensionIndexSlider; pub use self::text_log_column::TextLogColumn; pub use self::time_int::TimeInt; @@ -122,4 +125,5 @@ pub use self::visual_bounds2d::VisualBounds2D; pub use self::visualizer_component_mapping::VisualizerComponentMapping; pub use self::visualizer_instruction_id::VisualizerInstructionId; pub use self::visualizer_type::VisualizerType; +pub use self::web_page_url::WebPageUrl; pub use self::zoom_level::ZoomLevel; diff --git a/crates/store/re_sdk_types/src/blueprint/components/show_navigation_controls.rs b/crates/store/re_sdk_types/src/blueprint/components/show_navigation_controls.rs new file mode 100644 index 000000000000..c4060fa4e745 --- /dev/null +++ b/crates/store/re_sdk_types/src/blueprint/components/show_navigation_controls.rs @@ -0,0 +1,86 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/rust/api.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs". + +#![allow(unused_braces)] +#![allow(unused_imports)] +#![allow(unused_parens)] +#![allow(clippy::allow_attributes)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::cloned_instead_of_copied)] +#![allow(clippy::map_flatten)] +#![allow(clippy::needless_question_mark)] +#![allow(clippy::new_without_default)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::wildcard_imports)] + +use ::re_types_core::SerializationResult; +use ::re_types_core::try_serialize_field; +use ::re_types_core::{ComponentBatch as _, SerializedComponentBatch}; +use ::re_types_core::{ComponentDescriptor, ComponentType}; +use ::re_types_core::{DeserializationError, DeserializationResult}; + +/// **Component**: Whether browser navigation controls should be shown in the Web Page View. +/// +/// ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** +#[derive(Clone, Debug, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct ShowNavigationControls(pub crate::datatypes::Bool); + +impl ::re_types_core::WrapperComponent for ShowNavigationControls { + type Datatype = crate::datatypes::Bool; + + #[inline] + fn name() -> ComponentType { + "rerun.blueprint.components.ShowNavigationControls".into() + } + + #[inline] + fn into_inner(self) -> Self::Datatype { + self.0 + } +} + +::re_types_core::macros::impl_into_cow!(ShowNavigationControls); + +impl> From for ShowNavigationControls { + fn from(v: T) -> Self { + Self(v.into()) + } +} + +impl std::borrow::Borrow for ShowNavigationControls { + #[inline] + fn borrow(&self) -> &crate::datatypes::Bool { + &self.0 + } +} + +impl std::ops::Deref for ShowNavigationControls { + type Target = crate::datatypes::Bool; + + #[inline] + fn deref(&self) -> &crate::datatypes::Bool { + &self.0 + } +} + +impl std::ops::DerefMut for ShowNavigationControls { + #[inline] + fn deref_mut(&mut self) -> &mut crate::datatypes::Bool { + &mut self.0 + } +} + +impl ::re_byte_size::SizeBytes for ShowNavigationControls { + #[inline] + fn heap_size_bytes(&self) -> u64 { + self.0.heap_size_bytes() + } + + #[inline] + fn is_pod() -> bool { + ::is_pod() + } +} diff --git a/crates/store/re_sdk_types/src/blueprint/components/web_page_url.rs b/crates/store/re_sdk_types/src/blueprint/components/web_page_url.rs new file mode 100644 index 000000000000..ca6be2a0fc1d --- /dev/null +++ b/crates/store/re_sdk_types/src/blueprint/components/web_page_url.rs @@ -0,0 +1,86 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/rust/api.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs". + +#![allow(unused_braces)] +#![allow(unused_imports)] +#![allow(unused_parens)] +#![allow(clippy::allow_attributes)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::cloned_instead_of_copied)] +#![allow(clippy::map_flatten)] +#![allow(clippy::needless_question_mark)] +#![allow(clippy::new_without_default)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::wildcard_imports)] + +use ::re_types_core::SerializationResult; +use ::re_types_core::try_serialize_field; +use ::re_types_core::{ComponentBatch as _, SerializedComponentBatch}; +use ::re_types_core::{ComponentDescriptor, ComponentType}; +use ::re_types_core::{DeserializationError, DeserializationResult}; + +/// **Component**: The initial URL to load in a Web Page View. +/// +/// ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct WebPageUrl(pub crate::datatypes::Utf8); + +impl ::re_types_core::WrapperComponent for WebPageUrl { + type Datatype = crate::datatypes::Utf8; + + #[inline] + fn name() -> ComponentType { + "rerun.blueprint.components.WebPageUrl".into() + } + + #[inline] + fn into_inner(self) -> Self::Datatype { + self.0 + } +} + +::re_types_core::macros::impl_into_cow!(WebPageUrl); + +impl> From for WebPageUrl { + fn from(v: T) -> Self { + Self(v.into()) + } +} + +impl std::borrow::Borrow for WebPageUrl { + #[inline] + fn borrow(&self) -> &crate::datatypes::Utf8 { + &self.0 + } +} + +impl std::ops::Deref for WebPageUrl { + type Target = crate::datatypes::Utf8; + + #[inline] + fn deref(&self) -> &crate::datatypes::Utf8 { + &self.0 + } +} + +impl std::ops::DerefMut for WebPageUrl { + #[inline] + fn deref_mut(&mut self) -> &mut crate::datatypes::Utf8 { + &mut self.0 + } +} + +impl ::re_byte_size::SizeBytes for WebPageUrl { + #[inline] + fn heap_size_bytes(&self) -> u64 { + self.0.heap_size_bytes() + } + + #[inline] + fn is_pod() -> bool { + ::is_pod() + } +} diff --git a/crates/store/re_sdk_types/src/blueprint/views/.gitattributes b/crates/store/re_sdk_types/src/blueprint/views/.gitattributes index dcc2d8675efb..433892528714 100644 --- a/crates/store/re_sdk_types/src/blueprint/views/.gitattributes +++ b/crates/store/re_sdk_types/src/blueprint/views/.gitattributes @@ -13,3 +13,4 @@ tensor_view.rs linguist-generated=true text_document_view.rs linguist-generated=true text_log_view.rs linguist-generated=true time_series_view.rs linguist-generated=true +web_page_view.rs linguist-generated=true diff --git a/crates/store/re_sdk_types/src/blueprint/views/mod.rs b/crates/store/re_sdk_types/src/blueprint/views/mod.rs index 01fa75bf503f..5b73e883ba03 100644 --- a/crates/store/re_sdk_types/src/blueprint/views/mod.rs +++ b/crates/store/re_sdk_types/src/blueprint/views/mod.rs @@ -11,6 +11,7 @@ mod tensor_view; mod text_document_view; mod text_log_view; mod time_series_view; +mod web_page_view; pub use self::bar_chart_view::BarChartView; pub use self::dataframe_view::DataframeView; @@ -23,3 +24,4 @@ pub use self::tensor_view::TensorView; pub use self::text_document_view::TextDocumentView; pub use self::text_log_view::TextLogView; pub use self::time_series_view::TimeSeriesView; +pub use self::web_page_view::WebPageView; diff --git a/crates/store/re_sdk_types/src/blueprint/views/web_page_view.rs b/crates/store/re_sdk_types/src/blueprint/views/web_page_view.rs new file mode 100644 index 000000000000..d492aa702984 --- /dev/null +++ b/crates/store/re_sdk_types/src/blueprint/views/web_page_view.rs @@ -0,0 +1,85 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/rust/api.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/views/web_page.fbs". + +#![allow(unused_braces)] +#![allow(unused_imports)] +#![allow(unused_parens)] +#![allow(clippy::allow_attributes)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::cloned_instead_of_copied)] +#![allow(clippy::map_flatten)] +#![allow(clippy::needless_question_mark)] +#![allow(clippy::new_without_default)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::wildcard_imports)] + +use ::re_types_core::SerializationResult; +use ::re_types_core::try_serialize_field; +use ::re_types_core::{ComponentBatch as _, SerializedComponentBatch}; +use ::re_types_core::{ComponentDescriptor, ComponentType}; +use ::re_types_core::{DeserializationError, DeserializationResult}; + +/// **View**: A native-only view that embeds a configured web page. +/// +/// Web Page View is unsupported in the web viewer. Native support depends on the +/// operating-system webview runtime provided through `wry`: `WebView2` on Windows, +/// `WebKit` on macOS, and `WebKitGTK` on Linux. Linux child webviews may also depend +/// on the active display-server integration; the direct child-window path is +/// X11-oriented, while Wayland-capable embedding requires a GTK container path. +/// +/// ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** +#[derive(Clone, Debug)] +pub struct WebPageView { + /// Configuration for the web page to embed. + pub config: crate::blueprint::archetypes::WebPageViewConfig, +} + +impl ::re_types_core::View for WebPageView { + #[inline] + fn identifier() -> ::re_types_core::ViewClassIdentifier { + "WebPage".into() + } +} + +impl> From for WebPageView { + fn from(v: T) -> Self { + Self { config: v.into() } + } +} + +impl std::borrow::Borrow for WebPageView { + #[inline] + fn borrow(&self) -> &crate::blueprint::archetypes::WebPageViewConfig { + &self.config + } +} + +impl std::ops::Deref for WebPageView { + type Target = crate::blueprint::archetypes::WebPageViewConfig; + + #[inline] + fn deref(&self) -> &crate::blueprint::archetypes::WebPageViewConfig { + &self.config + } +} + +impl std::ops::DerefMut for WebPageView { + #[inline] + fn deref_mut(&mut self) -> &mut crate::blueprint::archetypes::WebPageViewConfig { + &mut self.config + } +} + +impl ::re_byte_size::SizeBytes for WebPageView { + #[inline] + fn heap_size_bytes(&self) -> u64 { + self.config.heap_size_bytes() + } + + #[inline] + fn is_pod() -> bool { + ::is_pod() + } +} diff --git a/crates/store/re_sdk_types/src/reflection/mod.rs b/crates/store/re_sdk_types/src/reflection/mod.rs index a0e7003ab013..0edbc448d4cf 100644 --- a/crates/store/re_sdk_types/src/reflection/mod.rs +++ b/crates/store/re_sdk_types/src/reflection/mod.rs @@ -436,6 +436,17 @@ fn generate_component_reflection() -> Result::name(), + ComponentReflection { + docstring_md: "Whether browser navigation controls should be shown in the Web Page View.\n\n⚠\u{fe0f} **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.**", + deprecation_summary: None, + custom_placeholder: Some(ShowNavigationControls::default().to_arrow()?), + datatype: ShowNavigationControls::arrow_datatype(), + is_enum: false, + verify_arrow_array: ShowNavigationControls::verify_arrow_array, + }, + ), ( ::name(), ComponentReflection { @@ -612,6 +623,17 @@ fn generate_component_reflection() -> Result::name(), + ComponentReflection { + docstring_md: "The initial URL to load in a Web Page View.\n\n⚠\u{fe0f} **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.**", + deprecation_summary: None, + custom_placeholder: Some(WebPageUrl::default().to_arrow()?), + datatype: WebPageUrl::arrow_datatype(), + is_enum: false, + verify_arrow_array: WebPageUrl::verify_arrow_array, + }, + ), ( ::name(), ComponentReflection { @@ -4722,6 +4744,31 @@ fn generate_archetype_reflection() -> ArchetypeReflectionMap { ], }, ), + ( + ArchetypeName::new("rerun.blueprint.archetypes.WebPageViewConfig"), + ArchetypeReflection { + display_name: "Web page view config", + deprecation_summary: None, + scope: Some("blueprint"), + view_types: &["WebPageView"], + fields: vec![ + ArchetypeFieldReflection { + name: "url", + display_name: "Url", + component_type: "rerun.blueprint.components.WebPageUrl".into(), + docstring_md: "The initial URL to load.", + flags: ArchetypeFieldFlags::REQUIRED | ArchetypeFieldFlags::UI_EDITABLE, + }, + ArchetypeFieldReflection { + name: "show_navigation_controls", + display_name: "Show navigation controls", + component_type: "rerun.blueprint.components.ShowNavigationControls".into(), + docstring_md: "Whether browser navigation controls should be shown.\n\nDefaults to true.", + flags: ArchetypeFieldFlags::UI_EDITABLE, + }, + ], + }, + ), ]; ArchetypeReflectionMap::from_iter(array) } diff --git a/crates/top/rerun-cli/Cargo.toml b/crates/top/rerun-cli/Cargo.toml index 9a106f47cfd0..9591db259f19 100644 --- a/crates/top/rerun-cli/Cargo.toml +++ b/crates/top/rerun-cli/Cargo.toml @@ -87,6 +87,9 @@ nasm = ["rerun/nasm"] ## This adds a lot of extra dependencies, so only enable this feature if you need it! native_viewer = ["rerun/native_viewer"] +## Experimental native embedded webview support for the Web Page View. +native_webview = ["native_viewer", "rerun/native_webview"] + ## Enable the in-memory Rerun Server, useful for testing. oss_server = ["rerun/oss_server"] diff --git a/crates/top/rerun/Cargo.toml b/crates/top/rerun/Cargo.toml index ab29a0d2cff7..aca41a80fc9c 100644 --- a/crates/top/rerun/Cargo.toml +++ b/crates/top/rerun/Cargo.toml @@ -90,6 +90,9 @@ nasm = ["re_video/nasm"] ## This adds a lot of extra dependencies, so only enable this feature if you need it! native_viewer = ["dep:re_viewer", "dep:re_crash_handler"] +## Experimental native embedded webview support for the Web Page View. +native_webview = ["native_viewer", "re_viewer/native_webview"] + ## Enable the in-memory Rerun Server, useful for testing. oss_server = ["dep:re_server"] diff --git a/crates/top/rerun/src/commands/entrypoint.rs b/crates/top/rerun/src/commands/entrypoint.rs index a2f68ed94a77..35a284ee829d 100644 --- a/crates/top/rerun/src/commands/entrypoint.rs +++ b/crates/top/rerun/src/commands/entrypoint.rs @@ -1760,6 +1760,7 @@ fn record_cli_command_analytics(args: &Args) { cors_allow_origin: _, port: _, new: _, + ws_url: _, } = args; let (command, subcommand) = match command { @@ -1834,7 +1835,14 @@ fn record_cli_command_analytics(args: &Args) { /// A function that wraps a `re_viewer::App` into a custom `Box`. /// Used by dimos-viewer to inject keyboard teleop and other behaviors. -pub type AppWrapper = Box Result, Box> + Send>; +pub type AppWrapper = Box< + dyn FnOnce( + re_viewer::App, + ) -> Result< + Box, + Box, + > + Send, +>; /// Optional patches to [`re_viewer::StartupOptions`] injected by the app wrapper. #[derive(Default)] diff --git a/crates/top/rerun/src/commands/mod.rs b/crates/top/rerun/src/commands/mod.rs index de14da278940..8675c73d5fee 100644 --- a/crates/top/rerun/src/commands/mod.rs +++ b/crates/top/rerun/src/commands/mod.rs @@ -38,8 +38,8 @@ mod analytics; pub(crate) use self::analytics::AnalyticsCommands; pub use self::download::DownloadCommand; pub use self::entrypoint::{ - run, run_with_app_wrapper, AppWrapper, StartupOptionsPatch, Args as RerunArgs, - native_startup_options_from_args, + AppWrapper, Args as RerunArgs, StartupOptionsPatch, native_startup_options_from_args, run, + run_with_app_wrapper, }; #[cfg(feature = "importers")] pub use self::mcap::McapCommands; diff --git a/crates/top/rerun/src/lib.rs b/crates/top/rerun/src/lib.rs index 272133e1ef25..0904bcf7f090 100644 --- a/crates/top/rerun/src/lib.rs +++ b/crates/top/rerun/src/lib.rs @@ -124,7 +124,10 @@ pub mod demo_util; pub mod log_integration; #[cfg(feature = "run")] -pub use commands::{CallSource, run, run_with_app_wrapper, AppWrapper, StartupOptionsPatch, RerunArgs, native_startup_options_from_args}; +pub use commands::{ + AppWrapper, CallSource, RerunArgs, StartupOptionsPatch, native_startup_options_from_args, run, + run_with_app_wrapper, +}; #[cfg(feature = "log")] pub use log_integration::Logger; #[cfg(feature = "log")] diff --git a/crates/viewer/re_view_web_page/Cargo.toml b/crates/viewer/re_view_web_page/Cargo.toml new file mode 100644 index 000000000000..03e52eb10723 --- /dev/null +++ b/crates/viewer/re_view_web_page/Cargo.toml @@ -0,0 +1,55 @@ +[package] +authors.workspace = true +description = "A native-only view that embeds a configured web page." +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "re_view_web_page" +publish = true +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true +include.workspace = true + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true + +[features] +default = [] +native_webview = ["dep:eframe", "dep:gtk", "dep:raw-window-handle", "dep:scoped-tls", "dep:wry"] + +[dependencies] +ahash.workspace = true +egui.workspace = true +parking_lot.workspace = true +re_log_types.workspace = true +re_sdk_types.workspace = true +re_tracing.workspace = true +re_ui.workspace = true +re_viewer_context.workspace = true +re_viewport_blueprint.workspace = true +url.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +eframe = { workspace = true, optional = true, default-features = false } +raw-window-handle = { workspace = true, optional = true } +scoped-tls = { workspace = true, optional = true } +wry = { workspace = true, optional = true, default-features = false, features = [ + "os-webview", + "x11", +] } + +# GTK/WebKitGTK is only used on Linux (see the `cfg(target_os = "linux")` platform +# module in `native_backend.rs`). Gating it here keeps the macOS/Windows builds from +# pulling in GTK system libraries. +[target.'cfg(target_os = "linux")'.dependencies] +gtk = { workspace = true, optional = true } + +[dev-dependencies] +egui_kittest.workspace = true +re_test_context.workspace = true +re_test_viewport.workspace = true diff --git a/crates/viewer/re_view_web_page/src/backend.rs b/crates/viewer/re_view_web_page/src/backend.rs new file mode 100644 index 000000000000..bb7c86df9c8c --- /dev/null +++ b/crates/viewer/re_view_web_page/src/backend.rs @@ -0,0 +1,209 @@ +use re_viewer_context::{ViewId, ViewerContext}; + +pub(crate) struct WebViewInstance { + view_id: ViewId, + pub(crate) url: String, + #[cfg(debug_assertions)] + fake_backend: Option, + has_native_webview: bool, +} + +impl std::fmt::Debug for WebViewInstance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebViewInstance") + .field("view_id", &self.view_id) + .field("url", &self.url) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WebViewSession { + SharedDefault, +} + +impl WebViewSession { + pub(crate) const fn shared_default() -> Self { + Self::SharedDefault + } + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::SharedDefault => "shared-default", + } + } +} + +impl WebViewInstance { + #[cfg(debug_assertions)] + pub(crate) fn new_fake( + view_id: ViewId, + url: String, + fake_backend: crate::testing::FakeWebViewBackend, + ) -> Self { + Self { + view_id, + url, + fake_backend: Some(fake_backend), + has_native_webview: false, + } + } + + pub(crate) fn new_native(view_id: ViewId, url: String) -> Self { + Self { + view_id, + url, + #[cfg(debug_assertions)] + fake_backend: None, + has_native_webview: true, + } + } + + pub(crate) fn set_bounds(&self, view_id: ViewId, bounds: WebViewBounds) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_bounds_update(view_id, bounds); + } + + if self.has_native_webview { + crate::native_backend::set_bounds(self.view_id, bounds); + } + } + + pub(crate) fn set_visible(&self, visible: bool) { + if self.has_native_webview { + crate::native_backend::set_visible(self.view_id, visible); + } + } + + pub(crate) fn go_back(&self) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_navigation_command(self.view_id, FakeNavigationCommand::Back); + } + + if self.has_native_webview { + crate::native_backend::go_back(self.view_id); + } + } + + pub(crate) fn go_forward(&self) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_navigation_command(self.view_id, FakeNavigationCommand::Forward); + } + + if self.has_native_webview { + crate::native_backend::go_forward(self.view_id); + } + } + + pub(crate) fn reload(&self) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_navigation_command(self.view_id, FakeNavigationCommand::Reload); + } + + if self.has_native_webview { + crate::native_backend::reload(self.view_id); + } + } + + pub(crate) fn navigate_to(&self, url: &str) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_navigation_command( + self.view_id, + FakeNavigationCommand::NavigateTo(url.to_owned()), + ); + } + + if self.has_native_webview { + crate::native_backend::navigate_to(self.view_id, url); + } + } +} + +#[cfg(debug_assertions)] +use crate::testing::FakeNavigationCommand; + +impl Drop for WebViewInstance { + fn drop(&mut self) { + #[cfg(debug_assertions)] + if let Some(fake_backend) = &self.fake_backend { + fake_backend.record_destroyed_instance(self.view_id, &self.url); + } + + if self.has_native_webview { + crate::native_backend::destroy(self.view_id); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct WebViewBounds { + pub(crate) min: [f32; 2], + pub(crate) size: [f32; 2], +} + +impl WebViewBounds { + pub(crate) fn from_egui_rect(rect: egui::Rect, pixels_per_point: f32) -> Self { + let min = rect.min * pixels_per_point; + let size = rect.size() * pixels_per_point; + Self { + min: [min.x.round(), min.y.round()], + size: [size.x.round(), size.y.round()], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WebViewBackendError { + CreationFailed(String), +} + +impl std::fmt::Display for WebViewBackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CreationFailed(message) => f.write_str(message), + } + } +} + +impl std::error::Error for WebViewBackendError {} + +pub(crate) trait WebViewBackend { + fn create( + &self, + ctx: &ViewerContext<'_>, + view_id: ViewId, + url: &str, + bounds: WebViewBounds, + session: WebViewSession, + ) -> Result; +} + +pub(crate) fn create_webview( + ctx: &ViewerContext<'_>, + view_id: ViewId, + url: &str, + bounds: WebViewBounds, +) -> Result, WebViewBackendError> { + #[cfg(debug_assertions)] + if let Some(fake_backend) = crate::testing::installed_backend() { + return fake_backend + .create(ctx, view_id, url, bounds, WebViewSession::shared_default()) + .map(Some); + } + + if crate::native_backend::has_native_parent_window() { + let native_webview = crate::native_backend::NativeWebViewBackend + .create_child(url, bounds) + .map_err(|err| WebViewBackendError::CreationFailed(err.to_string()))?; + crate::native_backend::insert(view_id, native_webview); + return Ok(Some(WebViewInstance::new_native(view_id, url.to_owned()))); + } + + let _ = (ctx, view_id, url, bounds); + Ok(None) +} diff --git a/crates/viewer/re_view_web_page/src/lib.rs b/crates/viewer/re_view_web_page/src/lib.rs new file mode 100644 index 000000000000..e3986e33bcfe --- /dev/null +++ b/crates/viewer/re_view_web_page/src/lib.rs @@ -0,0 +1,16 @@ +//! Native Web Page View. + +mod backend; +mod lifecycle; +#[cfg(all(not(target_arch = "wasm32"), feature = "native_webview"))] +#[path = "native_backend.rs"] +pub mod native_backend; +#[cfg(not(all(not(target_arch = "wasm32"), feature = "native_webview")))] +#[path = "native_backend_stub.rs"] +pub mod native_backend; +#[cfg(debug_assertions)] +pub mod testing; +mod url_policy; +mod view_class; + +pub use view_class::WebPageView; diff --git a/crates/viewer/re_view_web_page/src/lifecycle.rs b/crates/viewer/re_view_web_page/src/lifecycle.rs new file mode 100644 index 000000000000..4c9d541b48a0 --- /dev/null +++ b/crates/viewer/re_view_web_page/src/lifecycle.rs @@ -0,0 +1,86 @@ +use re_viewer_context::{ViewId, ViewerContext}; + +use crate::backend::{WebViewBounds, WebViewInstance, create_webview}; + +#[derive(Default)] +pub(crate) struct WebViewLifecycle { + webview: Option, + last_bounds: Option, +} + +impl WebViewLifecycle { + pub(crate) fn ensure_webview( + &mut self, + ctx: &ViewerContext<'_>, + view_id: ViewId, + url: &str, + bounds: WebViewBounds, + ) -> WebViewLifecycleStatus { + if let Some(webview) = &mut self.webview { + if webview.url != url { + webview.navigate_to(url); + webview.url = url.to_owned(); + } + } else { + match create_webview(ctx, view_id, url, bounds) { + Ok(Some(webview)) => { + self.webview = Some(webview); + self.last_bounds = None; + } + Ok(None) => return WebViewLifecycleStatus::Unavailable, + Err(err) => return WebViewLifecycleStatus::CreationFailed(err.to_string()), + } + } + + WebViewLifecycleStatus::Ready + } + + pub(crate) fn update_bounds(&mut self, view_id: ViewId, bounds: WebViewBounds) { + if self.last_bounds == Some(bounds) { + return; + } + + if let Some(webview) = &mut self.webview { + webview.set_bounds(view_id, bounds); + } + + self.last_bounds = Some(bounds); + } + + pub(crate) fn set_visible(&mut self, visible: bool) { + if let Some(webview) = &mut self.webview { + webview.set_visible(visible); + } + } + + pub(crate) fn go_back(&mut self) { + if let Some(webview) = &mut self.webview { + webview.go_back(); + } + } + + pub(crate) fn go_forward(&mut self) { + if let Some(webview) = &mut self.webview { + webview.go_forward(); + } + } + + pub(crate) fn reload(&mut self) { + if let Some(webview) = &mut self.webview { + webview.reload(); + } + } + + pub(crate) fn navigate_to(&mut self, url: &str) { + if let Some(webview) = &mut self.webview { + webview.navigate_to(url); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WebViewLifecycleStatus { + Ready, + Unavailable, + CreationFailed(String), +} diff --git a/crates/viewer/re_view_web_page/src/native_backend.rs b/crates/viewer/re_view_web_page/src/native_backend.rs new file mode 100644 index 000000000000..1423580ca4ef --- /dev/null +++ b/crates/viewer/re_view_web_page/src/native_backend.rs @@ -0,0 +1,289 @@ +//! Native `wry` backend for the Web Page View. +//! +//! This module is intentionally a thin boundary: callers provide the native parent window handle, +//! and UI/lifecycle code owns when this is called and how failures are surfaced. + +use re_viewer_context::ViewId; + +use crate::backend::WebViewBounds; + +thread_local! { + static NATIVE_WEBVIEWS: std::cell::RefCell> = + std::cell::RefCell::new(ahash::HashMap::default()); + static VISIBLE_THIS_FRAME: std::cell::RefCell> = + std::cell::RefCell::new(ahash::HashSet::default()); +} + +scoped_tls::scoped_thread_local!(static NATIVE_PARENT_WINDOW: eframe::Frame); + +#[derive(Debug, Default)] +pub struct NativeWebViewBackend; + +pub struct NativeWebView { + webview: wry::WebView, + visible: bool, +} + +#[derive(Debug)] +pub enum NativeWebViewError { + MissingParentWindow, + Wry(wry::Error), +} + +impl std::fmt::Display for NativeWebViewError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingParentWindow => f.write_str("missing parent native window"), + Self::Wry(err) => write!(f, "failed to create native webview: {err}"), + } + } +} + +impl std::error::Error for NativeWebViewError {} + +impl From for NativeWebViewError { + fn from(err: wry::Error) -> Self { + Self::Wry(err) + } +} + +impl NativeWebViewBackend { + pub fn create_without_parent_for_smoke_test( + &self, + _url: &str, + ) -> Result { + let _ = self; + Err(NativeWebViewError::MissingParentWindow) + } + + pub(crate) fn create_child( + &self, + url: &str, + bounds: WebViewBounds, + ) -> Result { + let _ = self; + NATIVE_PARENT_WINDOW.with(|parent_window| { + let webview = platform::create_child(parent_window, url, bounds)?; + Ok(NativeWebView { + webview, + visible: true, + }) + }) + } +} + +pub fn with_native_parent_window(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R { + NATIVE_PARENT_WINDOW.set(frame, || { + begin_frame(); + let result = f(); + hide_webviews_not_drawn_this_frame(); + platform::pump_events(); + result + }) +} + +pub(crate) fn has_native_parent_window() -> bool { + NATIVE_PARENT_WINDOW.is_set() +} + +impl NativeWebView { + pub(crate) fn set_bounds(&self, bounds: WebViewBounds) -> Result<(), NativeWebViewError> { + self.webview.set_bounds(bounds.into()).map_err(Into::into) + } + + pub(crate) fn set_visible(&mut self, visible: bool) -> Result<(), NativeWebViewError> { + if self.visible == visible { + return Ok(()); + } + + self.webview + .set_visible(visible) + .map_err(NativeWebViewError::from)?; + self.visible = visible; + Ok(()) + } + + pub(crate) fn go_back(&self) -> Result<(), NativeWebViewError> { + self.webview + .evaluate_script("history.back()") + .map_err(Into::into) + } + + pub(crate) fn go_forward(&self) -> Result<(), NativeWebViewError> { + self.webview + .evaluate_script("history.forward()") + .map_err(Into::into) + } + + pub(crate) fn reload(&self) -> Result<(), NativeWebViewError> { + self.webview.reload().map_err(Into::into) + } + + pub(crate) fn navigate_to(&self, url: &str) -> Result<(), NativeWebViewError> { + self.webview.load_url(url).map_err(Into::into) + } +} + +pub(crate) fn insert(view_id: ViewId, webview: NativeWebView) { + NATIVE_WEBVIEWS.with_borrow_mut(|webviews| { + webviews.insert(view_id, webview); + }); +} + +pub(crate) fn destroy(view_id: ViewId) { + NATIVE_WEBVIEWS.with_borrow_mut(|webviews| { + webviews.remove(&view_id); + }); +} + +pub(crate) fn set_bounds(view_id: ViewId, bounds: WebViewBounds) { + with_webview(view_id, |webview| webview.set_bounds(bounds)); +} + +pub(crate) fn set_visible(view_id: ViewId, visible: bool) { + if visible { + VISIBLE_THIS_FRAME.with_borrow_mut(|visible_this_frame| { + visible_this_frame.insert(view_id); + }); + } + + with_webview(view_id, |webview| webview.set_visible(visible)); +} + +pub(crate) fn go_back(view_id: ViewId) { + with_webview(view_id, |webview| webview.go_back()); +} + +pub(crate) fn go_forward(view_id: ViewId) { + with_webview(view_id, |webview| webview.go_forward()); +} + +pub(crate) fn reload(view_id: ViewId) { + with_webview(view_id, |webview| webview.reload()); +} + +pub(crate) fn navigate_to(view_id: ViewId, url: &str) { + with_webview(view_id, |webview| webview.navigate_to(url)); +} + +fn with_webview( + view_id: ViewId, + f: impl FnOnce(&mut NativeWebView) -> Result<(), NativeWebViewError>, +) { + NATIVE_WEBVIEWS.with_borrow_mut(|webviews| { + if let Some(webview) = webviews.get_mut(&view_id) { + match f(webview) { + Ok(()) | Err(_) => {} + } + } + }); +} + +fn begin_frame() { + VISIBLE_THIS_FRAME.with_borrow_mut(|visible_this_frame| { + visible_this_frame.clear(); + }); +} + +fn hide_webviews_not_drawn_this_frame() { + VISIBLE_THIS_FRAME.with_borrow(|visible_this_frame| { + NATIVE_WEBVIEWS.with_borrow_mut(|webviews| { + for (view_id, webview) in webviews { + if !visible_this_frame.contains(view_id) { + match webview.set_visible(false) { + Ok(()) | Err(_) => {} + } + } + } + }); + }); +} + +impl From for wry::Rect { + fn from(bounds: WebViewBounds) -> Self { + let min_x = bounds.min[0].max(0.0).round() as u32; + let min_y = bounds.min[1].max(0.0).round() as u32; + let width = bounds.size[0].max(1.0).round() as u32; + let height = bounds.size[1].max(1.0).round() as u32; + + // `WebViewBounds` is already in physical pixels (`from_egui_rect` multiplies the + // egui rect by `pixels_per_point`). + // + // On macOS/Windows the webview is a direct native child surface positioned in + // physical pixels, so it must be handed to wry as a *physical* rect — passing it + // as logical double-counts the scale factor on Retina/HiDPI displays, shifting and + // oversizing the webview off its tile. + // + // On Linux the X11 child-window path is positioned in GTK logical coordinates and + // only the scale-factor-1.0 case is supported (Wayland/HiDPI needs the GTK + // container path — see the `platform` module). Keep the original logical behavior + // there so the supported X11 path is unchanged. + #[cfg(target_os = "linux")] + { + Self { + position: wry::dpi::LogicalPosition::new(min_x, min_y).into(), + size: wry::dpi::LogicalSize::new(width, height).into(), + } + } + #[cfg(not(target_os = "linux"))] + { + Self { + position: wry::dpi::PhysicalPosition::new(min_x, min_y).into(), + size: wry::dpi::PhysicalSize::new(width, height).into(), + } + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use raw_window_handle::HasWindowHandle; + + pub(super) fn pump_events() { + if gtk::is_initialized_main_thread() { + // Keep WebKitGTK responsive without letting its event queue monopolize an egui frame. + // Further events will be drained on subsequent frames. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(4); + while std::time::Instant::now() < deadline { + if !gtk::events_pending() { + break; + } + gtk::main_iteration_do(false); + } + } + } + + pub(super) fn create_child( + parent_window: &W, + url: &str, + bounds: crate::backend::WebViewBounds, + ) -> wry::Result { + gtk::init()?; + + // `build_as_child` is the direct child-window path and is X11-only on Linux. + // Wayland support requires the GTK container path (`WebViewBuilderExtUnix::build_gtk`), + // which is intentionally hidden behind this platform module for a later host/widget bridge. + wry::WebViewBuilder::new() + .with_bounds(bounds.into()) + .with_url(url) + .build_as_child(parent_window) + } +} + +#[cfg(not(target_os = "linux"))] +mod platform { + use raw_window_handle::HasWindowHandle; + + pub(super) fn pump_events() {} + + pub(super) fn create_child( + parent_window: &W, + url: &str, + bounds: crate::backend::WebViewBounds, + ) -> wry::Result { + wry::WebViewBuilder::new() + .with_bounds(bounds.into()) + .with_url(url) + .build_as_child(parent_window) + } +} diff --git a/crates/viewer/re_view_web_page/src/native_backend_stub.rs b/crates/viewer/re_view_web_page/src/native_backend_stub.rs new file mode 100644 index 000000000000..aab7ff0fe04a --- /dev/null +++ b/crates/viewer/re_view_web_page/src/native_backend_stub.rs @@ -0,0 +1,56 @@ +//! No-op native backend used when embedded native webviews are not compiled in. + +use re_viewer_context::ViewId; + +use crate::backend::WebViewBounds; + +#[derive(Debug, Default)] +pub struct NativeWebViewBackend; + +pub struct NativeWebView; + +#[derive(Debug)] +pub enum NativeWebViewError { + Unavailable, +} + +impl std::fmt::Display for NativeWebViewError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable => f.write_str("native webview support is not available"), + } + } +} + +impl std::error::Error for NativeWebViewError {} + +impl NativeWebViewBackend { + pub(crate) fn create_child( + &self, + _url: &str, + _bounds: WebViewBounds, + ) -> Result { + let _ = self; + Err(NativeWebViewError::Unavailable) + } +} + +pub(crate) fn has_native_parent_window() -> bool { + false +} + +pub(crate) fn insert(_view_id: ViewId, _webview: NativeWebView) {} + +pub(crate) fn destroy(_view_id: ViewId) {} + +pub(crate) fn set_bounds(_view_id: ViewId, _bounds: WebViewBounds) {} + +pub(crate) fn set_visible(_view_id: ViewId, _visible: bool) {} + +pub(crate) fn go_back(_view_id: ViewId) {} + +pub(crate) fn go_forward(_view_id: ViewId) {} + +pub(crate) fn reload(_view_id: ViewId) {} + +pub(crate) fn navigate_to(_view_id: ViewId, _url: &str) {} diff --git a/crates/viewer/re_view_web_page/src/testing.rs b/crates/viewer/re_view_web_page/src/testing.rs new file mode 100644 index 000000000000..950b103bd625 --- /dev/null +++ b/crates/viewer/re_view_web_page/src/testing.rs @@ -0,0 +1,238 @@ +//! Test support for the Web Page View backend seam. + +use std::{cell::RefCell, collections::HashMap, sync::Arc}; + +use parking_lot::Mutex; +use re_viewer_context::{ViewId, ViewerContext}; + +use crate::backend::{ + WebViewBackend, WebViewBackendError, WebViewBounds, WebViewInstance, WebViewSession, +}; + +thread_local! { + static INSTALLED_BACKEND: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Debug, Clone, Default)] +pub struct FakeWebViewBackend { + state: Arc>, +} + +#[derive(Debug, Default)] +struct FakeWebViewBackendState { + creation_error: Option, + created_instances: Vec, + bounds_updates: Vec, + destroyed_instances: Vec, + navigation_commands: Vec, + current_urls: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeCreatedWebView { + pub view_id: ViewId, + pub url: String, + pub session: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeDestroyedWebView { + pub view_id: ViewId, + pub url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FakeWebViewBounds { + pub min: [f32; 2], + pub size: [f32; 2], +} + +impl From for FakeWebViewBounds { + fn from(bounds: WebViewBounds) -> Self { + Self { + min: bounds.min, + size: bounds.size, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FakeBoundsUpdate { + pub view_id: ViewId, + pub bounds: FakeWebViewBounds, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FakeNavigationCommand { + Back, + Forward, + Reload, + NavigateTo(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeNavigationCommandRequest { + pub view_id: ViewId, + pub command: FakeNavigationCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeNavigationRequest { + pub view_id: ViewId, + pub url: String, +} + +impl FakeWebViewBackend { + pub fn failing(message: impl Into) -> Self { + Self { + state: Arc::new(Mutex::new(FakeWebViewBackendState { + creation_error: Some(message.into()), + ..Default::default() + })), + } + } + + pub fn install(&self) -> FakeWebViewBackendGuard { + INSTALLED_BACKEND.with(|installed_backend| { + let previous_backend = installed_backend.replace(Some(self.clone())); + assert!( + previous_backend.is_none(), + "a fake Web Page View backend is already installed on this thread" + ); + }); + + FakeWebViewBackendGuard {} + } + + pub fn created_instance_count(&self) -> usize { + self.state.lock().created_instances.len() + } + + pub fn created_urls(&self) -> Vec { + self.state + .lock() + .created_instances + .iter() + .map(|created_instance| created_instance.url.clone()) + .collect() + } + + pub fn created_instances(&self) -> Vec { + self.state.lock().created_instances.clone() + } + + pub fn bounds_updates(&self) -> Vec { + self.state.lock().bounds_updates.clone() + } + + pub fn destroyed_instance_count(&self) -> usize { + self.state.lock().destroyed_instances.len() + } + + pub fn destroyed_instances(&self) -> Vec { + self.state.lock().destroyed_instances.clone() + } + + pub fn simulate_navigation(&self, view_id: ViewId, url: &str) { + self.state + .lock() + .current_urls + .insert(view_id, url.to_owned()); + } + + pub fn current_url(&self, view_id: ViewId) -> Option { + self.state.lock().current_urls.get(&view_id).cloned() + } + + pub fn navigation_requests(&self) -> Vec { + self.state + .lock() + .navigation_commands + .iter() + .filter_map(|request| match &request.command { + FakeNavigationCommand::NavigateTo(url) => Some(FakeNavigationRequest { + view_id: request.view_id, + url: url.clone(), + }), + FakeNavigationCommand::Back + | FakeNavigationCommand::Forward + | FakeNavigationCommand::Reload => None, + }) + .collect() + } + + pub(crate) fn record_bounds_update(&self, view_id: ViewId, bounds: WebViewBounds) { + self.state.lock().bounds_updates.push(FakeBoundsUpdate { + view_id, + bounds: bounds.into(), + }); + } + + pub(crate) fn record_destroyed_instance(&self, view_id: ViewId, url: &str) { + let mut state = self.state.lock(); + state.current_urls.remove(&view_id); + state.destroyed_instances.push(FakeDestroyedWebView { + view_id, + url: url.to_owned(), + }); + } + + pub(crate) fn record_navigation_command( + &self, + view_id: ViewId, + command: FakeNavigationCommand, + ) { + if let FakeNavigationCommand::NavigateTo(url) = &command { + self.state.lock().current_urls.insert(view_id, url.clone()); + } + + self.state + .lock() + .navigation_commands + .push(FakeNavigationCommandRequest { view_id, command }); + } +} + +pub struct FakeWebViewBackendGuard; + +impl Drop for FakeWebViewBackendGuard { + fn drop(&mut self) { + INSTALLED_BACKEND.with(|installed_backend| { + installed_backend.replace(None); + }); + } +} + +impl WebViewBackend for FakeWebViewBackend { + fn create( + &self, + _ctx: &ViewerContext<'_>, + view_id: ViewId, + url: &str, + _bounds: WebViewBounds, + session: WebViewSession, + ) -> Result { + let mut state = self.state.lock(); + + if let Some(message) = &state.creation_error { + return Err(WebViewBackendError::CreationFailed(message.clone())); + } + + state.created_instances.push(FakeCreatedWebView { + view_id, + url: url.to_owned(), + session: session.as_str().to_owned(), + }); + state.current_urls.insert(view_id, url.to_owned()); + + Ok(WebViewInstance::new_fake( + view_id, + url.to_owned(), + self.clone(), + )) + } +} + +pub(crate) fn installed_backend() -> Option { + INSTALLED_BACKEND.with(|installed_backend| installed_backend.borrow().clone()) +} diff --git a/crates/viewer/re_view_web_page/src/url_policy.rs b/crates/viewer/re_view_web_page/src/url_policy.rs new file mode 100644 index 000000000000..4609a384776a --- /dev/null +++ b/crates/viewer/re_view_web_page/src/url_policy.rs @@ -0,0 +1,54 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum UrlPolicyResult { + Accepted(String), + Invalid, + UnsupportedScheme(String), +} + +pub(crate) fn validate_url(url: &str) -> UrlPolicyResult { + match url::Url::parse(url) { + Ok(parsed_url) => match parsed_url.scheme() { + "http" | "https" => UrlPolicyResult::Accepted(url.to_owned()), + scheme => UrlPolicyResult::UnsupportedScheme(scheme.to_owned()), + }, + Err(_) => UrlPolicyResult::Invalid, + } +} + +#[cfg(test)] +mod tests { + use super::{UrlPolicyResult, validate_url}; + + #[test] + fn accepts_http_and_https_urls() { + assert_eq!( + validate_url("https://example.com"), + UrlPolicyResult::Accepted("https://example.com".to_owned()) + ); + assert_eq!( + validate_url("http://localhost:3000"), + UrlPolicyResult::Accepted("http://localhost:3000".to_owned()) + ); + } + + #[test] + fn rejects_unsupported_schemes() { + assert_eq!( + validate_url("file:///tmp/report.html"), + UrlPolicyResult::UnsupportedScheme("file".to_owned()) + ); + assert_eq!( + validate_url("javascript:alert(1)"), + UrlPolicyResult::UnsupportedScheme("javascript".to_owned()) + ); + assert_eq!( + validate_url("data:text/plain,hello"), + UrlPolicyResult::UnsupportedScheme("data".to_owned()) + ); + } + + #[test] + fn rejects_invalid_url_text() { + assert_eq!(validate_url("not a url"), UrlPolicyResult::Invalid); + } +} diff --git a/crates/viewer/re_view_web_page/src/view_class.rs b/crates/viewer/re_view_web_page/src/view_class.rs new file mode 100644 index 000000000000..ec111d7c0088 --- /dev/null +++ b/crates/viewer/re_view_web_page/src/view_class.rs @@ -0,0 +1,293 @@ +use re_log_types::EntityPath; +use re_sdk_types::blueprint::{ + archetypes::WebPageViewConfig, + components::{ShowNavigationControls, WebPageUrl}, +}; +use re_sdk_types::{View as _, ViewClassIdentifier}; +use re_ui::{Help, UiExt as _, icons}; +use re_viewer_context::{ + ViewClass, ViewClassLayoutPriority, ViewClassRegistryError, ViewId, ViewQuery, + ViewSpawnHeuristics, ViewState, ViewSystemExecutionError, ViewerContext, +}; +use re_viewport_blueprint::ViewProperty; + +use crate::backend::WebViewBounds; +use crate::lifecycle::{WebViewLifecycle, WebViewLifecycleStatus}; +use crate::url_policy::{UrlPolicyResult, validate_url}; + +#[derive(Default)] +struct WebPageViewState { + lifecycle: WebViewLifecycle, + address_bar_url: String, + address_bar_home_url: Option, + address_bar_error: Option, + pending_navigation_command: Option, +} + +impl ViewState for WebPageViewState { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +#[derive(Default)] +pub struct WebPageView; + +type ViewType = re_sdk_types::blueprint::views::WebPageView; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WebPageConfig { + url: Option, + show_navigation_controls: bool, +} + +impl WebPageConfig { + fn from_blueprint( + ctx: &ViewerContext<'_>, + query: &ViewQuery<'_>, + ) -> Result { + let config = ViewProperty::from_archetype::( + ctx.blueprint_db(), + ctx.blueprint_query, + query.view_id, + ); + + let url = config + .component_or_empty::(WebPageViewConfig::descriptor_url().component)? + .map(|url| url.0.0.as_str().to_owned()); + + let show_navigation_controls = config + .component_or_empty::( + WebPageViewConfig::descriptor_show_navigation_controls().component, + )? + .is_none_or(|show_navigation_controls| show_navigation_controls.0.0); + + Ok(Self { + url, + show_navigation_controls, + }) + } +} + +impl ViewClass for WebPageView { + fn identifier() -> ViewClassIdentifier { + ViewType::identifier() + } + + fn display_name(&self) -> &'static str { + "Web Page" + } + + fn icon(&self) -> &'static re_ui::Icon { + &icons::VIEW_GENERIC + } + + fn help(&self, _os: egui::os::OperatingSystem) -> Help { + Help::new("Web Page view") + .markdown("Displays a configured webpage inline in the native viewer.") + } + + fn on_register( + &self, + _system_registry: &mut re_viewer_context::ViewSystemRegistrator<'_>, + ) -> Result<(), ViewClassRegistryError> { + Ok(()) + } + + fn new_state(&self) -> Box { + Box::::default() + } + + fn layout_priority(&self) -> ViewClassLayoutPriority { + ViewClassLayoutPriority::Low + } + + fn spawn_heuristics( + &self, + _ctx: &ViewerContext<'_>, + _include_entity: &dyn Fn(&EntityPath) -> bool, + ) -> ViewSpawnHeuristics { + ViewSpawnHeuristics::empty() + } + + fn selection_ui( + &self, + _ctx: &ViewerContext<'_>, + _ui: &mut egui::Ui, + _state: &mut dyn ViewState, + _space_origin: &EntityPath, + _view_id: ViewId, + ) -> Result<(), ViewSystemExecutionError> { + Ok(()) + } + + fn ui( + &self, + ctx: &ViewerContext<'_>, + _missing_chunk_reporter: &re_viewer_context::MissingChunkReporter, + ui: &mut egui::Ui, + state: &mut dyn ViewState, + query: &ViewQuery<'_>, + _system_output: re_viewer_context::SystemExecutionOutput, + ) -> Result<(), ViewSystemExecutionError> { + re_tracing::profile_function!(); + + let config = WebPageConfig::from_blueprint(ctx, query)?; + let Some(url) = config.url else { + ui.centered_and_justified(|ui| { + ui.label("No URL configured"); + }); + return Ok(()); + }; + + let url = match validate_url(&url) { + UrlPolicyResult::Accepted(url) => url, + UrlPolicyResult::Invalid => { + ui.centered_and_justified(|ui| { + ui.label("Invalid URL"); + }); + return Ok(()); + } + UrlPolicyResult::UnsupportedScheme(scheme) => { + ui.centered_and_justified(|ui| { + ui.label(format!("Unsupported URL scheme: {scheme}")); + }); + return Ok(()); + } + }; + + let state = state + .as_any_mut() + .downcast_mut::() + .ok_or(ViewSystemExecutionError::StateCastError("WebPageViewState"))?; + + if state.address_bar_home_url.as_deref() != Some(url.as_str()) { + state.address_bar_url.clone_from(&url); + state.address_bar_home_url = Some(url.clone()); + state.address_bar_error = None; + } + + if config.show_navigation_controls { + let mut navigation_command = None; + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + navigation_command = Some(NavigationCommand::Back); + } + if ui.button("Forward").clicked() { + navigation_command = Some(NavigationCommand::Forward); + } + if ui.button("Reload").clicked() { + navigation_command = Some(NavigationCommand::Reload); + } + if ui.button("Home").clicked() { + navigation_command = Some(NavigationCommand::Home); + } + let label = ui.label("Address"); + let go_button_width = + ui.spacing().interact_size.x + ui.spacing().button_padding.x * 2.0; + let address_width = + (ui.available_width() - go_button_width - ui.spacing().item_spacing.x) + .max(80.0); + let response = ui + .add( + egui::TextEdit::singleline(&mut state.address_bar_url) + .desired_width(address_width) + .hint_text("Address"), + ) + .labelled_by(label.id); + let go_clicked = ui.button("Go").clicked(); + if go_clicked + || response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) + { + match validate_url(&state.address_bar_url) { + UrlPolicyResult::Accepted(address_bar_url) => { + state.address_bar_url.clone_from(&address_bar_url); + state.address_bar_error = None; + navigation_command = + Some(NavigationCommand::NavigateTo(address_bar_url)); + } + UrlPolicyResult::Invalid => { + state.address_bar_error = Some("Invalid URL".to_owned()); + } + UrlPolicyResult::UnsupportedScheme(scheme) => { + state.address_bar_error = + Some(format!("Unsupported URL scheme: {scheme}")); + } + } + } + }); + if let Some(error) = &state.address_bar_error { + ui.error_label(error); + } + ui.separator(); + + if matches!(navigation_command, Some(NavigationCommand::Home)) { + state.address_bar_url.clone_from(&url); + state.address_bar_error = None; + } + + state.pending_navigation_command = navigation_command; + } else { + state.pending_navigation_command = None; + } + + let webview_rect = ui.available_rect_before_wrap(); + let webview_bounds = + WebViewBounds::from_egui_rect(webview_rect, ui.ctx().pixels_per_point()); + let lifecycle_status = + state + .lifecycle + .ensure_webview(ctx, query.view_id, &url, webview_bounds); + + match lifecycle_status { + WebViewLifecycleStatus::Ready => { + state.lifecycle.update_bounds(query.view_id, webview_bounds); + state.lifecycle.set_visible(true); + if !ctx.app_ctx.is_test { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); + } + match state.pending_navigation_command.take() { + Some(NavigationCommand::Back) => state.lifecycle.go_back(), + Some(NavigationCommand::Forward) => state.lifecycle.go_forward(), + Some(NavigationCommand::Reload) => state.lifecycle.reload(), + Some(NavigationCommand::Home) => { + state.lifecycle.navigate_to(&url); + } + Some(NavigationCommand::NavigateTo(address_bar_url)) => { + state.lifecycle.navigate_to(&address_bar_url); + } + None => {} + } + ui.allocate_rect(webview_rect, egui::Sense::hover()); + } + WebViewLifecycleStatus::Unavailable => { + ui.centered_and_justified(|ui| { + ui.label("Embedded webview unavailable"); + }); + } + WebViewLifecycleStatus::CreationFailed(message) => { + ui.centered_and_justified(|ui| { + ui.vertical_centered(|ui| { + ui.label("Failed to create embedded webview"); + ui.label(message); + }); + }); + } + } + + Ok(()) + } +} + +enum NavigationCommand { + Back, + Forward, + Reload, + Home, + NavigateTo(String), +} diff --git a/crates/viewer/re_view_web_page/tests/native_backend.rs b/crates/viewer/re_view_web_page/tests/native_backend.rs new file mode 100644 index 000000000000..fb5a307f5f65 --- /dev/null +++ b/crates/viewer/re_view_web_page/tests/native_backend.rs @@ -0,0 +1,14 @@ +#![cfg(all(not(target_arch = "wasm32"), feature = "native_webview"))] + +use re_view_web_page::native_backend::{NativeWebViewBackend, NativeWebViewError}; + +#[test] +fn native_backend_reports_missing_parent_window_without_panicking() { + let result = + NativeWebViewBackend::default().create_without_parent_for_smoke_test("https://example.com"); + + assert!(matches!( + result, + Err(NativeWebViewError::MissingParentWindow) + )); +} diff --git a/crates/viewer/re_view_web_page/tests/web_page_view.rs b/crates/viewer/re_view_web_page/tests/web_page_view.rs new file mode 100644 index 000000000000..6999dd8e02ba --- /dev/null +++ b/crates/viewer/re_view_web_page/tests/web_page_view.rs @@ -0,0 +1,603 @@ +use egui_kittest::kittest::Queryable as _; +use re_sdk_types::blueprint::archetypes::WebPageViewConfig; +use re_test_context::TestContext; +use re_test_viewport::TestContextExt as _; +use re_view_web_page::WebPageView; +use re_view_web_page::testing::FakeWebViewBackend; +use re_viewer_context::{BlueprintContext as _, ViewClass as _, ViewerContext}; +use re_viewport_blueprint::{ViewBlueprint, ViewProperty, ViewportBlueprint}; + +#[test] +fn manually_created_web_page_view_without_url_shows_status() { + let mut test_context = TestContext::new_with_view_class::(); + + let view_id = test_context.setup_viewport_blueprint(|_ctx, blueprint| { + blueprint.add_view_at_root(ViewBlueprint::new_with_root_wildcard( + WebPageView::identifier(), + )) + }); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness + .query_by_label_contains("No URL configured") + .is_some(), + "expected Web Page View to explain that no URL is configured" + ); +} + +#[test] +fn blueprint_configured_web_page_view_reads_url_and_navigation_preference_without_logged_data() { + let mut test_context = TestContext::new_with_view_class::(); + + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", false); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness + .query_by_label_contains("No URL configured") + .is_none(), + "expected configured Web Page View not to render the missing-URL status" + ); + assert!( + harness.query_by_label_contains("Back").is_none(), + "expected navigation controls to be hidden when show_navigation_controls is false" + ); + assert!( + harness.query_by_label_contains("Address").is_none(), + "expected address label to be hidden when show_navigation_controls is false" + ); + assert!( + harness + .query_by_label_contains("https://example.com") + .is_none(), + "expected configured URL label to be hidden when show_navigation_controls is false" + ); +} + +#[test] +fn https_url_is_accepted() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert_eq!( + harness + .get_by_role_and_label(egui::accesskit::Role::TextInput, "Address") + .value() + .as_deref(), + Some("https://example.com") + ); + assert!(harness.query_by_label_contains("Invalid URL").is_none()); + assert!( + harness + .query_by_label_contains("Unsupported URL scheme") + .is_none() + ); +} + +#[test] +fn localhost_http_url_is_accepted() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "http://localhost:3000", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert_eq!( + harness + .get_by_role_and_label(egui::accesskit::Role::TextInput, "Address") + .value() + .as_deref(), + Some("http://localhost:3000") + ); + assert!(harness.query_by_label_contains("Invalid URL").is_none()); + assert!( + harness + .query_by_label_contains("Unsupported URL scheme") + .is_none() + ); +} + +#[test] +fn navigation_controls_are_visible_by_default() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_web_page_view_with_default_navigation_controls( + &mut test_context, + "https://example.com", + ); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!(harness.query_by_label_contains("Back").is_some()); + assert!(harness.query_by_label_contains("Forward").is_some()); + assert!(harness.query_by_label_contains("Reload").is_some()); + assert!(harness.query_by_label_contains("Home").is_some()); +} + +#[test] +fn navigation_controls_can_be_hidden() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", false); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!(harness.query_by_label_contains("Back").is_none()); + assert!(harness.query_by_label_contains("Forward").is_none()); + assert!(harness.query_by_label_contains("Reload").is_none()); + assert!(harness.query_by_label_contains("Home").is_none()); + assert!(harness.query_by_label_contains("Address").is_none()); + assert!( + harness + .query_by_label_contains("https://example.com") + .is_none() + ); +} + +#[test] +fn home_navigation_does_not_mutate_configured_url() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let configured_url = "https://example.com/home"; + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, configured_url, true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + + fake_backend.simulate_navigation(view_id, "https://example.com/runtime"); + harness.get_by_label("Home").click(); + harness.run(); + + let navigation_requests = fake_backend.navigation_requests(); + assert_eq!(navigation_requests.len(), 1); + assert_eq!(navigation_requests[0].view_id, view_id); + assert_eq!(navigation_requests[0].url, configured_url); + assert_eq!( + harness + .get_by_role_and_label(egui::accesskit::Role::TextInput, "Address") + .value() + .as_deref(), + Some(configured_url) + ); +} + +#[test] +fn navigation_controls_include_editable_address_bar() { + let configured_url = "https://example.com/home"; + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, configured_url, true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([700.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + + let address_bar = harness.get_by_role_and_label(egui::accesskit::Role::TextInput, "Address"); + assert_eq!(address_bar.value().as_deref(), Some(configured_url)); + assert!(harness.query_by_label("Go").is_some()); +} + +#[test] +fn file_url_shows_unsupported_scheme_status() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = + setup_configured_web_page_view(&mut test_context, "file:///tmp/report.html", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness + .query_by_label_contains("Unsupported URL scheme") + .is_some(), + "expected file URLs to be rejected by Rerun-side status UI" + ); +} + +#[test] +fn invalid_url_text_shows_invalid_status() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "not a url", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness.query_by_label_contains("Invalid URL").is_some(), + "expected invalid URL text to be rejected by Rerun-side status UI" + ); +} + +#[test] +fn unavailable_native_backend_shows_status_outside_webview() { + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness + .query_by_label_contains("Embedded webview unavailable") + .is_some(), + "expected explicit status when no native backend is available" + ); +} + +#[test] +fn backend_creation_failure_shows_status_outside_webview() { + let fake_backend = FakeWebViewBackend::failing("backend failed"); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert!( + harness + .query_by_label_contains("Failed to create embedded webview") + .is_some(), + "expected explicit status when backend creation fails" + ); + assert!(harness.query_by_label_contains("backend failed").is_some()); +} + +#[test] +fn valid_configured_url_creates_one_backend_webview_instance() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + + harness.run(); + + assert_eq!(fake_backend.created_instance_count(), 1); + assert_eq!(fake_backend.created_urls(), ["https://example.com"]); +} + +#[test] +fn changed_configured_url_navigates_existing_backend_webview() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com/a", true); + + { + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + } + + assert_eq!(fake_backend.created_urls(), ["https://example.com/a"]); + assert_eq!( + fake_backend.current_url(view_id).as_deref(), + Some("https://example.com/a") + ); + + update_configured_web_page_view(&mut test_context, view_id, "https://example.com/b", true); + + { + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + } + + let created_instances = fake_backend.created_instances(); + assert_eq!(created_instances.len(), 1); + assert_eq!(created_instances[0].view_id, view_id); + assert_eq!(created_instances[0].url, "https://example.com/a"); + + assert_eq!(fake_backend.destroyed_instance_count(), 0); + + let navigation_requests = fake_backend.navigation_requests(); + assert_eq!(navigation_requests.len(), 1); + assert_eq!(navigation_requests[0].view_id, view_id); + assert_eq!(navigation_requests[0].url, "https://example.com/b"); + assert_eq!( + fake_backend.current_url(view_id).as_deref(), + Some("https://example.com/b") + ); +} + +#[test] +fn two_web_page_views_create_independent_backend_webview_instances() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let (first_view_id, second_view_id) = + test_context.setup_viewport_blueprint(|ctx, blueprint| { + let first_view_id = + add_configured_web_page_view(ctx, blueprint, "https://example.com/first", true); + let second_view_id = + add_configured_web_page_view(ctx, blueprint, "https://example.com/second", true); + (first_view_id, second_view_id) + }); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 500.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, first_view_id); + test_context.run_with_single_view(ui, second_view_id); + }); + + harness.run(); + + let created_instances = fake_backend.created_instances(); + assert_eq!(created_instances.len(), 2); + assert_eq!(created_instances[0].view_id, first_view_id); + assert_eq!(created_instances[0].url, "https://example.com/first"); + assert_eq!(created_instances[1].view_id, second_view_id); + assert_eq!(created_instances[1].url, "https://example.com/second"); +} + +#[test] +fn multiple_web_page_views_use_shared_default_browser_session() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let (first_view_id, second_view_id) = + test_context.setup_viewport_blueprint(|ctx, blueprint| { + let first_view_id = + add_configured_web_page_view(ctx, blueprint, "https://example.com/first", true); + let second_view_id = + add_configured_web_page_view(ctx, blueprint, "https://example.com/second", true); + (first_view_id, second_view_id) + }); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 500.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, first_view_id); + test_context.run_with_single_view(ui, second_view_id); + }); + + harness.run(); + + let created_instances = fake_backend.created_instances(); + assert_eq!(created_instances.len(), 2); + assert_eq!(created_instances[0].session, created_instances[1].session); + assert_eq!(created_instances[0].session.as_str(), "shared-default"); +} + +#[test] +fn backend_receives_updated_bounds_when_view_rect_changes() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut first_harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + first_harness.run(); + + let mut second_harness = test_context + .setup_kittest_for_rendering_ui([700.0, 350.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + second_harness.run(); + + let bounds_updates = fake_backend.bounds_updates(); + assert!( + bounds_updates.len() >= 2, + "expected bounds updates from both rendered view sizes" + ); + assert!( + bounds_updates + .iter() + .all(|bounds_update| bounds_update.view_id == view_id) + ); + assert_ne!( + bounds_updates.first().unwrap().bounds.size, + bounds_updates.last().unwrap().bounds.size + ); +} + +#[test] +fn hidden_web_page_view_keeps_backend_instance_alive() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + + assert_eq!(fake_backend.created_instance_count(), 1); + + // Simulate a hidden tab by not rendering the view for a frame. The view state remains owned by + // the viewer and therefore must keep its native webview alive. + let mut hidden_harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|_ui| {}); + hidden_harness.run(); + + assert_eq!(fake_backend.destroyed_instance_count(), 0); +} + +#[test] +fn removed_web_page_view_destroys_backend_instance() { + let fake_backend = FakeWebViewBackend::default(); + let _backend_guard = fake_backend.install(); + + let mut test_context = TestContext::new_with_view_class::(); + let view_id = setup_configured_web_page_view(&mut test_context, "https://example.com", true); + + let mut harness = test_context + .setup_kittest_for_rendering_ui([500.0, 250.0]) + .build_ui(|ui| { + test_context.run_with_single_view(ui, view_id); + }); + harness.run(); + + assert_eq!(fake_backend.created_instance_count(), 1); + + test_context + .view_states + .lock() + .retain_for_views(&test_context.recording_store_id, []); + + assert_eq!(fake_backend.destroyed_instance_count(), 1); +} + +fn setup_configured_web_page_view( + test_context: &mut TestContext, + url: &str, + show_navigation_controls: bool, +) -> re_viewer_context::ViewId { + test_context.setup_viewport_blueprint(|ctx, blueprint| { + add_configured_web_page_view(ctx, blueprint, url, show_navigation_controls) + }) +} + +fn setup_web_page_view_with_default_navigation_controls( + test_context: &mut TestContext, + url: &str, +) -> re_viewer_context::ViewId { + test_context.setup_viewport_blueprint(|ctx, blueprint| { + let view = ViewBlueprint::new_with_root_wildcard(WebPageView::identifier()); + let config = ViewProperty::from_archetype::( + ctx.blueprint_db(), + ctx.blueprint_query, + view.id, + ); + + ctx.save_blueprint_archetype(config.blueprint_store_path, &WebPageViewConfig::new(url)); + + blueprint.add_view_at_root(view) + }) +} + +fn update_configured_web_page_view( + test_context: &mut TestContext, + view_id: re_viewer_context::ViewId, + url: &str, + show_navigation_controls: bool, +) { + test_context.setup_viewport_blueprint(|ctx, _blueprint| { + let config = ViewProperty::from_archetype::( + ctx.blueprint_db(), + ctx.blueprint_query, + view_id, + ); + + ctx.save_blueprint_archetype( + config.blueprint_store_path, + &WebPageViewConfig::new(url).with_show_navigation_controls(show_navigation_controls), + ); + }); +} + +fn add_configured_web_page_view( + ctx: &ViewerContext<'_>, + blueprint: &mut ViewportBlueprint, + url: &str, + show_navigation_controls: bool, +) -> re_viewer_context::ViewId { + let view = ViewBlueprint::new_with_root_wildcard(WebPageView::identifier()); + let config = ViewProperty::from_archetype::( + ctx.blueprint_db(), + ctx.blueprint_query, + view.id, + ); + + ctx.save_blueprint_archetype( + config.blueprint_store_path, + &WebPageViewConfig::new(url).with_show_navigation_controls(show_navigation_controls), + ); + + blueprint.add_view_at_root(view) +} diff --git a/crates/viewer/re_viewer/Cargo.toml b/crates/viewer/re_viewer/Cargo.toml index ca70511c5b83..0911b5ff42f9 100644 --- a/crates/viewer/re_viewer/Cargo.toml +++ b/crates/viewer/re_viewer/Cargo.toml @@ -43,6 +43,11 @@ analytics = ["dep:re_analytics", "re_ui/analytics"] ## Enable the map view map_view = ["dep:re_view_map"] +## Enable the native Web Page View backend. +## +## This only works on native. +native_webview = ["re_view_web_page/native_webview"] + ## Enables integration with `re_perf_telemetry` (OpenTelemetry, Jaeger). ## ## This only works on native. @@ -108,6 +113,7 @@ re_view_tensor.workspace = true re_view_text_document.workspace = true re_view_text_log.workspace = true re_view_time_series.workspace = true +re_view_web_page.workspace = true re_viewer_context.workspace = true re_viewport_blueprint.workspace = true re_viewport.workspace = true diff --git a/crates/viewer/re_viewer/src/app.rs b/crates/viewer/re_viewer/src/app.rs index 5267815fd104..5cccccb4068b 100644 --- a/crates/viewer/re_viewer/src/app.rs +++ b/crates/viewer/re_viewer/src/app.rs @@ -158,6 +158,22 @@ pub struct App { async_runtime: AsyncRuntimeHandle, } +/// Request to open or update a Web Page View in the active viewport. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebPageViewRequest { + /// Caller-owned stable identifier used to update the same panel later. + pub panel_id: String, + + /// Human-readable panel title. + pub title: String, + + /// Configured page URL. + pub url: String, + + /// Whether browser-like controls should be visible. + pub show_navigation_controls: bool, +} + impl App { pub fn new( main_thread_token: MainThreadToken, @@ -488,6 +504,15 @@ impl App { &self.connection_registry } + /// Queue a Web Page View panel request for the active viewport. + /// + /// This is intended for thin custom viewer wrappers such as `DimOS`. The request is translated + /// into normal blueprint state on the next viewer frame. + pub fn open_or_update_web_page_view(&mut self, request: WebPageViewRequest) { + self.state.queue_web_page_view_request(request); + self.egui_ctx.request_repaint(); + } + pub fn set_examples_manifest_url(&mut self, url: String) { re_log::info!("Using manifest_url={url:?}"); self.state.set_examples_manifest_url(&self.egui_ctx, url); @@ -2527,6 +2552,33 @@ impl App { let empty_store_context = ActiveStoreContext::empty(); let active_store_context = store_context.unwrap_or(&empty_store_context); + #[cfg(all(not(target_arch = "wasm32"), feature = "native_webview"))] + re_view_web_page::native_backend::with_native_parent_window(frame, || { + self.state.show( + &self.app_env, + &self.startup_options, + app_blueprint, + ui, + render_ctx, + active_store_context, + storage_context, + &self.reflection, + &self.component_ui_registry, + &self.component_fallback_registry, + &self.view_class_registry, + &self.rx_log, + &self.command_sender, + &WelcomeScreenState { + hide_examples: self.startup_options.hide_welcome_screen, + opacity: self.welcome_screen_opacity(ui), + }, + self.event_dispatcher.as_ref(), + &self.connection_registry, + &self.async_runtime, + ); + }); + + #[cfg(any(target_arch = "wasm32", not(feature = "native_webview")))] self.state.show( &self.app_env, &self.startup_options, diff --git a/crates/viewer/re_viewer/src/app_state.rs b/crates/viewer/re_viewer/src/app_state.rs index 83d8f267ac65..22ba89e1dd51 100644 --- a/crates/viewer/re_viewer/src/app_state.rs +++ b/crates/viewer/re_viewer/src/app_state.rs @@ -11,7 +11,9 @@ use re_log_channel::{LogReceiverSet, LogSource, RecordingOpenBehavior}; use re_log_types::{AbsoluteTimeRangeF, StoreId, TableId}; use re_redap_browser::RedapServers; use re_redap_client::ConnectionRegistryHandle; +use re_sdk_types::blueprint::archetypes::{self as blueprint_archetypes, WebPageViewConfig}; use re_sdk_types::blueprint::components::{PanelState, PlayState}; +use re_sdk_types::components::Name; use re_ui::{ContextExt as _, UiExt as _}; use re_viewer_context::open_url::{self, ViewerOpenUrl}; use re_viewer_context::{ @@ -19,13 +21,14 @@ use re_viewer_context::{ AsyncRuntimeHandle, AuthContext, BlueprintContext, BlueprintUndoState, CommandSender, ComponentUiRegistry, DragAndDropManager, FallbackProviderRegistry, FocusTarget, Item, Route, SelectionChange, StorageContext, StoreHub, StoreViewContext, SystemCommand, - SystemCommandSender as _, TableStore, TimeControl, TimeControlCommand, ViewClassRegistry, - ViewStates, ViewerContext, blueprint_timeline, + SystemCommandSender as _, TableStore, TimeControl, TimeControlCommand, ViewClass as _, + ViewClassRegistry, ViewId, ViewStates, ViewerContext, blueprint_timeline, }; use re_viewport::ViewportUi; -use re_viewport_blueprint::ViewportBlueprint; use re_viewport_blueprint::ui::add_view_or_container_modal_ui; +use re_viewport_blueprint::{ViewBlueprint, ViewProperty, ViewportBlueprint}; +use crate::app::WebPageViewRequest; use crate::app_blueprint::AppBlueprint; use crate::navigation::Navigation; use crate::open_url_description::ViewerOpenUrlDescription; @@ -121,6 +124,14 @@ pub struct AppState { /// Are we logged in? #[serde(skip)] pub(crate) auth_state: Option, + + /// Queued external requests to create or update Web Page View panels. + #[serde(skip)] + pending_web_page_view_requests: Vec, + + /// Runtime-only mapping from external panel ids to viewer view ids. + #[serde(skip)] + web_page_panel_ids: HashMap, } impl Default for AppState { @@ -146,6 +157,8 @@ impl Default for AppState { selection_state: Default::default(), focused_item: Default::default(), auth_state: Default::default(), + pending_web_page_view_requests: Default::default(), + web_page_panel_ids: Default::default(), #[cfg(feature = "testing")] test_hook: None, @@ -177,6 +190,10 @@ impl AppState { self.welcome_screen.set_examples_manifest_url(egui_ctx, url); } + pub fn queue_web_page_view_request(&mut self, request: WebPageViewRequest) { + self.pending_web_page_view_requests.push(request); + } + pub fn app_options(&self) -> &AppOptions { &self.app_options } @@ -301,6 +318,8 @@ impl AppState { selection_state, focused_item, auth_state, + pending_web_page_view_requests, + web_page_panel_ids, .. } = self; @@ -474,6 +493,13 @@ impl AppState { egui_ctx.request_repaint(); } + apply_pending_web_page_view_requests( + &ctx, + &viewport_ui.blueprint, + pending_web_page_view_requests, + web_page_panel_ids, + ); + // Update the viewport. May spawn new views and handle queued requests (like screenshots). viewport_ui.on_frame_start(&ctx); @@ -1025,3 +1051,57 @@ impl re_byte_size::MemUsageTreeCapture for AppState { tree.into_tree() } } + +fn apply_pending_web_page_view_requests( + ctx: &ViewerContext<'_>, + viewport_blueprint: &ViewportBlueprint, + pending_requests: &mut Vec, + panel_ids: &mut HashMap, +) { + for request in pending_requests.drain(..) { + let view_id = panel_ids + .get(&request.panel_id) + .copied() + .filter(|view_id| viewport_blueprint.view(view_id).is_some()) + .unwrap_or_else(|| { + let mut view = ViewBlueprint::new_with_root_wildcard( + re_view_web_page::WebPageView::identifier(), + ); + view.display_name = Some(request.title.clone()); + + let view_id = viewport_blueprint.add_view_at_root(view); + + // Adding a view via the DimOS command counts as a user edit: otherwise + // auto-layout heuristics re-derive the layout from logged data every frame + // and drop the (data-less) Web Page View before it can be shown. + viewport_blueprint.mark_user_interaction(ctx); + + panel_ids.insert(request.panel_id.clone(), view_id); + view_id + }); + + save_web_page_view_request(ctx, view_id, &request); + viewport_blueprint.focus_tab(view_id); + } +} + +fn save_web_page_view_request( + ctx: &ViewerContext<'_>, + view_id: ViewId, + request: &WebPageViewRequest, +) { + ctx.save_blueprint_component( + view_id.as_entity_path(), + &blueprint_archetypes::ViewBlueprint::descriptor_display_name(), + &Name::from(request.title.clone()), + ); + + let config_property = ViewProperty::from_archetype::( + ctx.blueprint_db(), + ctx.blueprint_query, + view_id, + ); + let config = WebPageViewConfig::new(request.url.clone()) + .with_show_navigation_controls(request.show_navigation_controls); + ctx.save_blueprint_archetype(config_property.blueprint_store_path, &config); +} diff --git a/crates/viewer/re_viewer/src/blueprint/validation_gen/mod.rs b/crates/viewer/re_viewer/src/blueprint/validation_gen/mod.rs index af6f3504e9b1..1a1de0c009d5 100644 --- a/crates/viewer/re_viewer/src/blueprint/validation_gen/mod.rs +++ b/crates/viewer/re_viewer/src/blueprint/validation_gen/mod.rs @@ -40,6 +40,7 @@ pub use re_sdk_types::blueprint::components::QueryExpression; pub use re_sdk_types::blueprint::components::RootContainer; pub use re_sdk_types::blueprint::components::RowShare; pub use re_sdk_types::blueprint::components::SelectedColumns; +pub use re_sdk_types::blueprint::components::ShowNavigationControls; pub use re_sdk_types::blueprint::components::TensorDimensionIndexSlider; pub use re_sdk_types::blueprint::components::TextLogColumn; pub use re_sdk_types::blueprint::components::TimeInt; @@ -56,6 +57,7 @@ pub use re_sdk_types::blueprint::components::VisualBounds2D; pub use re_sdk_types::blueprint::components::VisualizerComponentMapping; pub use re_sdk_types::blueprint::components::VisualizerInstructionId; pub use re_sdk_types::blueprint::components::VisualizerType; +pub use re_sdk_types::blueprint::components::WebPageUrl; pub use re_sdk_types::blueprint::components::ZoomLevel; /// Because blueprints are both read and written the schema must match what @@ -98,6 +100,7 @@ pub fn is_valid_blueprint(blueprint: &EntityDb) -> bool { && validate_component::(blueprint) && validate_component::(blueprint) && validate_component::(blueprint) + && validate_component::(blueprint) && validate_component::(blueprint) && validate_component::(blueprint) && validate_component::(blueprint) @@ -114,5 +117,6 @@ pub fn is_valid_blueprint(blueprint: &EntityDb) -> bool { && validate_component::(blueprint) && validate_component::(blueprint) && validate_component::(blueprint) + && validate_component::(blueprint) && validate_component::(blueprint) } diff --git a/crates/viewer/re_viewer/src/default_views.rs b/crates/viewer/re_viewer/src/default_views.rs index b7ca47cefa4b..cb416dd5be18 100644 --- a/crates/viewer/re_viewer/src/default_views.rs +++ b/crates/viewer/re_viewer/src/default_views.rs @@ -77,6 +77,11 @@ fn populate_view_class_registry_with_builtin( app_options, fallback_registry, )?; + view_class_registry.add_class::( + reflection, + app_options, + fallback_registry, + )?; if app_options.experimental.enable_status_view { view_class_registry.add_class::( reflection, diff --git a/crates/viewer/re_viewer/src/lib.rs b/crates/viewer/re_viewer/src/lib.rs index 1dacec0b394a..a209285b6c25 100644 --- a/crates/viewer/re_viewer/src/lib.rs +++ b/crates/viewer/re_viewer/src/lib.rs @@ -63,7 +63,7 @@ mod loading; /// Unstable. Used for the ongoing blueprint experimentations. pub mod blueprint; -pub use app::App; +pub use app::{App, WebPageViewRequest}; pub(crate) use app_state::AppState; pub use event::{SelectionChangeItem, ViewerEvent, ViewerEventKind}; pub use re_capabilities::MainThreadToken; diff --git a/crates/viewer/re_viewer_context/src/view/view_states.rs b/crates/viewer/re_viewer_context/src/view/view_states.rs index 0da995bdcd8c..f33c055134d0 100644 --- a/crates/viewer/re_viewer_context/src/view/view_states.rs +++ b/crates/viewer/re_viewer_context/src/view/view_states.rs @@ -3,7 +3,7 @@ //! The `Viewer` has ownership of this state and pass it around to users (mainly viewport and //! selection panel). -use ahash::HashMap; +use ahash::{HashMap, HashSet}; use re_log_types::StoreId; @@ -45,6 +45,22 @@ impl re_byte_size::SizeBytes for ViewStates { } impl ViewStates { + pub fn retain_for_views( + &mut self, + store_id: &StoreId, + retained_view_ids: impl IntoIterator, + ) { + let retained_view_ids: HashSet = retained_view_ids.into_iter().collect(); + + self.states.retain(|(state_store_id, view_id), _| { + state_store_id != store_id || retained_view_ids.contains(view_id) + }); + self.visualizer_reports + .retain(|(state_store_id, view_id), _| { + state_store_id != store_id || retained_view_ids.contains(view_id) + }); + } + pub fn get(&self, store_id: &StoreId, view_id: ViewId) -> Option<&dyn ViewState> { self.states .get(&(store_id.clone(), view_id)) diff --git a/crates/viewer/re_viewport/src/viewport_ui.rs b/crates/viewer/re_viewport/src/viewport_ui.rs index a77f18b29741..9c6f5ca77e32 100644 --- a/crates/viewer/re_viewport/src/viewport_ui.rs +++ b/crates/viewer/re_viewport/src/viewport_ui.rs @@ -86,6 +86,7 @@ impl ViewportUi { }; // Reset all error states. + view_states.retain_for_views(ctx.store_id(), blueprint.view_ids().copied()); view_states.reset_visualizer_reports(); let executed_systems_per_view = diff --git a/dimos/Cargo.toml b/dimos/Cargo.toml index db8b34bf5e43..6648d213f06f 100644 --- a/dimos/Cargo.toml +++ b/dimos/Cargo.toml @@ -30,6 +30,8 @@ bincode.workspace = true clap.workspace = true futures-util.workspace = true mimalloc.workspace = true +parking_lot.workspace = true +re_log = { workspace = true, features = ["setup"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true tokio = { workspace = true, features = [ @@ -42,6 +44,12 @@ tokio = { workspace = true, features = [ "time", ] } tokio-tungstenite = "0.28.0" +url.workspace = true + +# Used to install a macOS Edit menu so the native Web Page View can receive +# cmd+C / cmd+V via the responder chain (see `macos_menu.rs`). +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" [lints] workspace = true diff --git a/dimos/pyproject.toml b/dimos/pyproject.toml index 81fa2e3e4d4f..cec8ca1413c2 100644 --- a/dimos/pyproject.toml +++ b/dimos/pyproject.toml @@ -8,18 +8,18 @@ version = "0.32.0a1" description = "Interactive Rerun viewer for DimOS with click-to-navigate support" readme = "README.md" requires-python = ">=3.10" -license = {text = "MIT OR Apache-2.0"} -authors = [{name = "Dimensional Inc.", email = "engineering@dimensional.com"}] +license = { text = "MIT OR Apache-2.0" } +authors = [{ name = "Dimensional Inc.", email = "engineering@dimensional.com" }] classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Topic :: Scientific/Engineering", - "Topic :: Scientific/Engineering :: Visualization", - "Programming Language :: Rust", - "License :: OSI Approved :: MIT License", - "License :: OSI Approved :: Apache Software License", - "Operating System :: POSIX :: Linux", - "Operating System :: MacOS", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Visualization", + "Programming Language :: Rust", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", ] [project.urls] diff --git a/dimos/src/interaction/handle.rs b/dimos/src/interaction/handle.rs index 0f71a6f11fd6..65e25a007d6c 100644 --- a/dimos/src/interaction/handle.rs +++ b/dimos/src/interaction/handle.rs @@ -1,5 +1,5 @@ -use tokio::sync::mpsc; use super::protocol::ViewerEvent; +use tokio::sync::mpsc; /// Handle for sending interaction events from the viewer to the application. /// @@ -34,8 +34,8 @@ impl InteractionHandle { is_2d, }; - if let Err(e) = self.tx.send(event) { - eprintln!("Failed to send click event: {}", e); + if let Err(err) = self.tx.send(event) { + re_log::warn!("Failed to send click event: {err}"); } } } @@ -58,7 +58,13 @@ mod tests { let event = rx.try_recv().unwrap(); match event { - ViewerEvent::Click { position, entity_path, view_id, is_2d, .. } => { + ViewerEvent::Click { + position, + entity_path, + view_id, + is_2d, + .. + } => { assert_eq!(position, [1.0, 2.0, 3.0]); assert_eq!(entity_path, Some("world/robot".to_string())); assert_eq!(view_id, "view_123"); diff --git a/dimos/src/interaction/keyboard.rs b/dimos/src/interaction/keyboard.rs index 291586df7024..236b3f4e5eae 100644 --- a/dimos/src/interaction/keyboard.rs +++ b/dimos/src/interaction/keyboard.rs @@ -10,9 +10,9 @@ use super::ws::WsPublisher; use rerun::external::{egui, re_log}; /// Base speeds for keyboard control -const BASE_LINEAR_SPEED: f64 = 0.5; // m/s -const BASE_ANGULAR_SPEED: f64 = 0.8; // rad/s -const FAST_MULTIPLIER: f64 = 2.0; // Shift modifier +const BASE_LINEAR_SPEED: f64 = 0.5; // m/s +const BASE_ANGULAR_SPEED: f64 = 0.8; // rad/s +const FAST_MULTIPLIER: f64 = 2.0; // Shift modifier /// Overlay styling const OVERLAY_PADDING: f32 = 10.0; @@ -29,13 +29,13 @@ const ESTOP_ACTIVE_BG: egui::Color32 = egui::Color32::from_rgb(220, 50, 50); /// Tracks which movement keys are currently held down. #[derive(Debug, Clone, Default)] struct KeyState { - forward: bool, // W or Up - backward: bool, // S or Down - left: bool, // A or Left - right: bool, // D or Right - strafe_l: bool, // Q - strafe_r: bool, // E - fast: bool, // Shift held + forward: bool, // W or Up + backward: bool, // S or Down + left: bool, // A or Left + right: bool, // D or Right + strafe_l: bool, // Q + strafe_r: bool, // E + fast: bool, // Shift held } impl KeyState { @@ -66,8 +66,8 @@ pub struct KeyboardHandler { ws: WsPublisher, state: KeyState, was_active: bool, - estop_flash: bool, // true briefly after space pressed - engaged: bool, // true when user has clicked the overlay to activate + estop_flash: bool, // true briefly after space pressed + engaged: bool, // true when user has clicked the overlay to activate } impl KeyboardHandler { @@ -93,8 +93,8 @@ impl KeyboardHandler { // If not engaged, don't capture any keys if !self.engaged { if self.was_active { - if let Err(e) = self.publish_stop() { - re_log::warn!("Failed to send stop on disengage: {e}"); + if let Err(err) = self.publish_stop() { + re_log::warn!("Failed to send stop on disengage: {err}"); } self.was_active = false; } @@ -107,8 +107,8 @@ impl KeyboardHandler { // Check for emergency stop (Space key pressed - one-shot action) if ctx.input(|i| i.key_pressed(egui::Key::Space)) { self.state.reset(); - if let Err(e) = self.publish_stop() { - re_log::warn!("Failed to send emergency stop: {e}"); + if let Err(err) = self.publish_stop() { + re_log::warn!("Failed to send emergency stop: {err}"); } self.was_active = false; self.estop_flash = true; @@ -117,13 +117,13 @@ impl KeyboardHandler { // Publish twist command if keys are active, or stop if just released if self.state.any_active() { - if let Err(e) = self.publish_twist() { - re_log::warn!("Failed to publish twist command: {e}"); + if let Err(err) = self.publish_twist() { + re_log::warn!("Failed to publish twist command: {err}"); } self.was_active = true; } else if self.was_active { - if let Err(e) = self.publish_stop() { - re_log::warn!("Failed to send stop on key release: {e}"); + if let Err(err) = self.publish_stop() { + re_log::warn!("Failed to send stop on key release: {err}"); } self.was_active = false; } @@ -201,7 +201,11 @@ impl KeyboardHandler { fn draw_hud_content(&self, ui: &mut egui::Ui) { // Title - ui.label(egui::RichText::new("Keyboard Teleop").color(LABEL_COLOR).size(13.0)); + ui.label( + egui::RichText::new("Keyboard Teleop") + .color(LABEL_COLOR) + .size(13.0), + ); ui.add_space(4.0); // Key grid: [Q] [W] [E] @@ -236,16 +240,19 @@ impl KeyboardHandler { // Space bar (e-stop) let space_width = KEY_SIZE * 3.0 + KEY_GAP * 2.0; - let space_rect = ui.allocate_exact_size( - egui::vec2(space_width, KEY_SIZE * 0.7), - egui::Sense::hover(), - ).0; + let space_rect = ui + .allocate_exact_size( + egui::vec2(space_width, KEY_SIZE * 0.7), + egui::Sense::hover(), + ) + .0; let space_bg = if self.estop_flash { ESTOP_ACTIVE_BG } else { KEY_INACTIVE_BG }; - ui.painter().rect_filled(space_rect, egui::CornerRadius::same(4), space_bg); + ui.painter() + .rect_filled(space_rect, egui::CornerRadius::same(4), space_bg); ui.painter().text( space_rect.center(), egui::Align2::CENTER_CENTER, @@ -257,22 +264,33 @@ impl KeyboardHandler { ui.add_space(4.0); // Speed indicator - let speed_label = if self.state.fast { "⇧ FAST" } else { "⇧ shift=fast" }; + let speed_label = if self.state.fast { + "⇧ FAST" + } else { + "⇧ shift=fast" + }; let speed_color = if self.state.fast { egui::Color32::from_rgb(255, 200, 50) } else { LABEL_COLOR }; - ui.label(egui::RichText::new(speed_label).color(speed_color).size(10.0)); + ui.label( + egui::RichText::new(speed_label) + .color(speed_color) + .size(10.0), + ); } fn draw_key(&self, ui: &mut egui::Ui, label: &str, pressed: bool) { - let (rect, _) = ui.allocate_exact_size( - egui::vec2(KEY_SIZE, KEY_SIZE), - egui::Sense::hover(), - ); - let bg = if pressed { KEY_ACTIVE_BG } else { KEY_INACTIVE_BG }; - ui.painter().rect_filled(rect, egui::CornerRadius::same(4), bg); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(KEY_SIZE, KEY_SIZE), egui::Sense::hover()); + let bg = if pressed { + KEY_ACTIVE_BG + } else { + KEY_INACTIVE_BG + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(4), bg); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, @@ -296,24 +314,24 @@ impl KeyboardHandler { } /// Convert current KeyState to Twist and publish via WebSocket. - fn publish_twist(&mut self) -> Result<(), super::ws::SendError> { + fn publish_twist(&self) -> Result<(), super::ws::SendError> { let (lin_x, lin_y, lin_z, ang_x, ang_y, ang_z) = self.compute_twist(); - self.ws.send_twist(lin_x, lin_y, lin_z, ang_x, ang_y, ang_z)?; + self.ws + .send_twist(lin_x, lin_y, lin_z, ang_x, ang_y, ang_z)?; if std::env::var("DIMOS_DEBUG").is_ok_and(|v| v == "1") { - eprintln!( - "[DIMOS_DEBUG] Published twist: lin=({:.2},{:.2},{:.2}) ang=({:.2},{:.2},{:.2})", - lin_x, lin_y, lin_z, ang_x, ang_y, ang_z + re_log::debug!( + "[DIMOS_DEBUG] Published twist: lin=({lin_x:.2},{lin_y:.2},{lin_z:.2}) ang=({ang_x:.2},{ang_y:.2},{ang_z:.2})" ); } Ok(()) } /// Publish all-zero twist (stop command) via WebSocket. - fn publish_stop(&mut self) -> Result<(), super::ws::SendError> { + fn publish_stop(&self) -> Result<(), super::ws::SendError> { self.ws.send_stop()?; if std::env::var("DIMOS_DEBUG").is_ok_and(|v| v == "1") { - eprintln!("[DIMOS_DEBUG] Published stop command"); + re_log::debug!("[DIMOS_DEBUG] Published stop command"); } Ok(()) } diff --git a/dimos/src/interaction/mod.rs b/dimos/src/interaction/mod.rs index 76ab58df72d3..78fe38e7b79f 100644 --- a/dimos/src/interaction/mod.rs +++ b/dimos/src/interaction/mod.rs @@ -6,4 +6,4 @@ pub mod ws; pub use handle::InteractionHandle; pub use keyboard::KeyboardHandler; pub use protocol::ViewerEvent; -pub use ws::{SendError, WsPublisher}; +pub use ws::{SendError, WsCommand, WsPublisher}; diff --git a/dimos/src/interaction/ws.rs b/dimos/src/interaction/ws.rs index a05636b5488b..de1530d3ad02 100644 --- a/dimos/src/interaction/ws.rs +++ b/dimos/src/interaction/ws.rs @@ -13,10 +13,10 @@ //! {"type":"stop"} //! ``` -use std::time::Duration; +use std::{sync::Arc, time::Duration}; -use rerun::external::re_log; -use serde::Serialize; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; /// Error returned when a WebSocket event cannot be sent. @@ -24,6 +24,7 @@ use tokio::sync::mpsc; pub enum SendError { /// The send queue is full; the event was dropped. QueueFull, + /// Failed to serialize the event to JSON. Serialize(String), } @@ -59,6 +60,74 @@ pub enum WsEvent { Stop, } +/// JSON message variants received from the WebSocket server. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum WsCommand { + /// Request that DimOS opens or updates a Web Page View panel. + OpenWebPageView { + /// Caller-owned stable identifier used to update the same panel later. + panel_id: String, + + /// Human-readable panel title. + title: String, + + /// Configured page URL. + url: String, + + /// Whether browser-like controls should be visible. + show_navigation_controls: bool, + }, +} + +/// Error returned when an inbound WebSocket command is not safe to apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WsCommandValidationError { + /// URL text could not be parsed as an absolute URL. + InvalidUrl, + + /// URL uses a scheme that Web Page View does not allow. + UnsupportedUrlScheme(String), +} + +impl std::fmt::Display for WsCommandValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUrl => write!(f, "invalid URL"), + Self::UnsupportedUrlScheme(scheme) => write!(f, "unsupported URL scheme: {scheme}"), + } + } +} + +impl WsCommand { + /// Validate command fields that affect viewer state. + pub fn validate(&self) -> Result<(), WsCommandValidationError> { + match self { + Self::OpenWebPageView { url, .. } => validate_web_page_url(url), + } + } +} + +fn validate_web_page_url(url: &str) -> Result<(), WsCommandValidationError> { + let parsed = match url::Url::parse(url) { + Ok(parsed) => parsed, + Err(err) => { + let _err = err; + return Err(WsCommandValidationError::InvalidUrl); + } + }; + match parsed.scheme() { + "http" | "https" => Ok(()), + scheme => Err(WsCommandValidationError::UnsupportedUrlScheme( + scheme.to_owned(), + )), + } +} + +fn parse_command(text: &str) -> Result { + serde_json::from_str(text) +} + /// Sends `WsEvent`s (serialized to JSON) to a remote WebSocket server. /// /// Maintains a persistent connection with automatic reconnection. The @@ -67,6 +136,7 @@ pub enum WsEvent { #[derive(Clone)] pub struct WsPublisher { tx: mpsc::Sender, + command_rx: Arc>>, } impl WsPublisher { @@ -79,34 +149,50 @@ impl WsPublisher { /// so it works even when called from a non-async context (like the eframe UI). pub fn connect(url: String) -> Self { let (tx, rx) = mpsc::channel::(256); + let (command_tx, command_rx) = mpsc::channel::(256); // Spawn a dedicated thread with its own tokio runtime. // This allows WsPublisher to work from the eframe UI thread which // doesn't have a tokio runtime. std::thread::Builder::new() - .name("ws-publisher".to_string()) + .name("ws-publisher".to_owned()) .spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to create tokio runtime for WsPublisher"); - rt.block_on(run_client(url, rx)); + rt.block_on(run_client(url, rx, command_tx)); }) .expect("failed to spawn WsPublisher thread"); - Self { tx } + Self { + tx, + command_rx: Arc::new(Mutex::new(command_rx)), + } + } + + /// Try to receive one inbound command without blocking the UI thread. + pub fn try_recv_command(&self) -> Option { + self.command_rx.lock().try_recv().ok() } /// Publish a click event. - pub fn send_click(&self, x: f64, y: f64, z: f64, entity_path: &str, timestamp_ms: u64) -> Result<(), SendError> { + pub fn send_click( + &self, + x: f64, + y: f64, + z: f64, + entity_path: &str, + timestamp_ms: u64, + ) -> Result<(), SendError> { let event = WsEvent::Click { x, y, z, - entity_path: entity_path.to_string(), + entity_path: entity_path.to_owned(), timestamp_ms, }; - self.broadcast(event) + self.broadcast(&event) } /// Publish a twist (velocity) command. @@ -127,18 +213,22 @@ impl WsPublisher { angular_y, angular_z, }; - self.broadcast(event) + self.broadcast(&event) } /// Publish a stop command. pub fn send_stop(&self) -> Result<(), SendError> { - self.broadcast(WsEvent::Stop) + self.broadcast(&WsEvent::Stop) } - fn broadcast(&self, event: WsEvent) -> Result<(), SendError> { - let json = serde_json::to_string(&event).map_err(|e| SendError::Serialize(e.to_string()))?; + fn broadcast(&self, event: &WsEvent) -> Result<(), SendError> { + let json = + serde_json::to_string(&event).map_err(|err| SendError::Serialize(err.to_string()))?; // Non-blocking: error if the channel is full rather than block the UI thread. - self.tx.try_send(json).map_err(|_| SendError::QueueFull) + self.tx.try_send(json).map_err(|err| { + let _err = err; + SendError::QueueFull + }) } } @@ -148,21 +238,25 @@ fn is_debug() -> bool { } /// Background task: connect → send → reconnect loop. -async fn run_client(url: String, mut rx: mpsc::Receiver) { - use futures_util::{SinkExt, StreamExt}; +async fn run_client( + url: String, + mut rx: mpsc::Receiver, + command_tx: mpsc::Sender, +) { + use futures_util::{SinkExt as _, StreamExt as _}; use tokio_tungstenite::{connect_async, tungstenite::Message}; let debug = is_debug(); loop { if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: connecting to {url}"); + re_log::debug!("[DIMOS_DEBUG] WsPublisher: connecting to {url}"); } match connect_async(&url).await { Ok((ws_stream, _)) => { if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: connected to {url}"); + re_log::debug!("[DIMOS_DEBUG] WsPublisher: connected to {url}"); } let (mut writer, mut reader) = ws_stream.split(); @@ -171,18 +265,39 @@ async fn run_client(url: String, mut rx: mpsc::Receiver) { // server's keepalive pings get answered and the connection stays // alive. Exits when the server closes or an error occurs. let debug_read = debug; + let command_tx_read = command_tx.clone(); let mut read_handle = tokio::spawn(async move { while let Some(frame) = reader.next().await { match frame { + Ok(Message::Text(text)) => match parse_command(&text) { + Ok(command) => { + if let Err(err) = command_tx_read.try_send(command) + && debug_read + { + re_log::warn!( + "[DIMOS_DEBUG] WsPublisher: inbound command dropped: {err}" + ); + } + } + Err(err) => { + if debug_read { + re_log::debug!( + "[DIMOS_DEBUG] WsPublisher: ignoring inbound message: {err}" + ); + } + } + }, Ok(Message::Close(_)) => { if debug_read { - eprintln!("[DIMOS_DEBUG] WsPublisher: server sent close frame"); + re_log::debug!( + "[DIMOS_DEBUG] WsPublisher: server sent close frame" + ); } break; } Err(err) => { if debug_read { - eprintln!("[DIMOS_DEBUG] WsPublisher: read error: {err}"); + re_log::warn!("[DIMOS_DEBUG] WsPublisher: read error: {err}"); } break; } @@ -199,7 +314,7 @@ async fn run_client(url: String, mut rx: mpsc::Receiver) { Some(text) => { if let Err(err) = writer.send(Message::text(text)).await { if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: send error: {err} — reconnecting"); + re_log::warn!("[DIMOS_DEBUG] WsPublisher: send error: {err} — reconnecting"); } break false; } @@ -210,7 +325,7 @@ async fn run_client(url: String, mut rx: mpsc::Receiver) { _ = &mut read_handle => { // Reader exited → server closed the connection. if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: server closed connection — reconnecting"); + re_log::debug!("[DIMOS_DEBUG] WsPublisher: server closed connection — reconnecting"); } break false; } @@ -219,14 +334,16 @@ async fn run_client(url: String, mut rx: mpsc::Receiver) { if disconnected { if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: channel closed, shutting down"); + re_log::debug!("[DIMOS_DEBUG] WsPublisher: channel closed, shutting down"); } break; } } Err(err) => { if debug { - eprintln!("[DIMOS_DEBUG] WsPublisher: connection failed: {err} — retrying in 1s"); + re_log::warn!( + "[DIMOS_DEBUG] WsPublisher: connection failed: {err} — retrying in 1s" + ); } } } @@ -238,3 +355,160 @@ async fn run_client(url: String, mut rx: mpsc::Receiver) { tokio::time::sleep(Duration::from_secs(1)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_publisher_with_command_rx(command_rx: mpsc::Receiver) -> WsPublisher { + let (tx, _rx) = mpsc::channel::(1); + WsPublisher { + tx, + command_rx: Arc::new(Mutex::new(command_rx)), + } + } + + #[test] + fn parses_open_web_page_view_command() { + let command: WsCommand = serde_json::from_str( + r#"{ + "type": "open_web_page_view", + "panel_id": "viser", + "title": "Viser", + "url": "http://127.0.0.1:8095/", + "show_navigation_controls": true + }"#, + ) + .expect("valid open_web_page_view command should parse"); + + assert_eq!( + command, + WsCommand::OpenWebPageView { + panel_id: "viser".to_owned(), + title: "Viser".to_owned(), + url: "http://127.0.0.1:8095/".to_owned(), + show_navigation_controls: true, + } + ); + } + + #[test] + fn outbound_events_keep_existing_wire_format() { + let click = serde_json::to_value(WsEvent::Click { + x: 1.0, + y: 2.0, + z: 3.0, + entity_path: "/world".to_owned(), + timestamp_ms: 1234567890, + }) + .expect("click event should serialize"); + assert_eq!( + click, + serde_json::json!({ + "type": "click", + "x": 1.0, + "y": 2.0, + "z": 3.0, + "entity_path": "/world", + "timestamp_ms": 1234567890_u64, + }) + ); + + let twist = serde_json::to_value(WsEvent::Twist { + linear_x: 0.5, + linear_y: 0.0, + linear_z: 0.0, + angular_x: 0.0, + angular_y: 0.0, + angular_z: 0.8, + }) + .expect("twist event should serialize"); + assert_eq!( + twist, + serde_json::json!({ + "type": "twist", + "linear_x": 0.5, + "linear_y": 0.0, + "linear_z": 0.0, + "angular_x": 0.0, + "angular_y": 0.0, + "angular_z": 0.8, + }) + ); + + let stop = serde_json::to_value(WsEvent::Stop).expect("stop event should serialize"); + assert_eq!(stop, serde_json::json!({ "type": "stop" })); + } + + #[test] + fn try_recv_command_drains_inbound_queue_without_blocking() { + let (command_tx, command_rx) = mpsc::channel::(1); + command_tx + .try_send(WsCommand::OpenWebPageView { + panel_id: "viser".to_owned(), + title: "Viser".to_owned(), + url: "http://127.0.0.1:8095/".to_owned(), + show_navigation_controls: true, + }) + .expect("test command queue should have capacity"); + + let publisher = test_publisher_with_command_rx(command_rx); + + assert_eq!( + publisher.try_recv_command(), + Some(WsCommand::OpenWebPageView { + panel_id: "viser".to_owned(), + title: "Viser".to_owned(), + url: "http://127.0.0.1:8095/".to_owned(), + show_navigation_controls: true, + }) + ); + assert_eq!(publisher.try_recv_command(), None); + } + + #[test] + fn unknown_or_malformed_inbound_messages_are_rejected_by_parser() { + assert!(parse_command(r#"{"type":"unknown"}"#).is_err()); + assert!(parse_command(r#"{"type":"open_web_page_view","panel_id":"viser"}"#).is_err()); + assert!(parse_command("not json").is_err()); + } + + #[test] + fn layout_placement_fields_are_rejected_by_parser() { + assert!( + parse_command( + r#"{ + "type": "open_web_page_view", + "panel_id": "viser", + "title": "Viser", + "url": "http://127.0.0.1:8095/", + "show_navigation_controls": true, + "split_direction": "right" + }"#, + ) + .is_err() + ); + } + + #[test] + fn web_page_command_url_policy_matches_web_page_view() { + assert_eq!(validate_web_page_url("https://rerun.io"), Ok(())); + assert_eq!(validate_web_page_url("http://127.0.0.1:8095/"), Ok(())); + assert_eq!( + validate_web_page_url("file:///tmp/report.html"), + Err(WsCommandValidationError::UnsupportedUrlScheme( + "file".to_owned() + )) + ); + assert_eq!( + validate_web_page_url("javascript:alert(1)"), + Err(WsCommandValidationError::UnsupportedUrlScheme( + "javascript".to_owned() + )) + ); + assert_eq!( + validate_web_page_url("not a url"), + Err(WsCommandValidationError::InvalidUrl) + ); + } +} diff --git a/dimos/src/lib.rs b/dimos/src/lib.rs index a397390f3710..179cc9bceee4 100644 --- a/dimos/src/lib.rs +++ b/dimos/src/lib.rs @@ -1 +1,2 @@ pub mod interaction; +pub mod macos_menu; diff --git a/dimos/src/macos_menu.rs b/dimos/src/macos_menu.rs new file mode 100644 index 000000000000..0905f479b892 --- /dev/null +++ b/dimos/src/macos_menu.rs @@ -0,0 +1,81 @@ +//! macOS application menu setup. +//! +//! eframe/winit apps don't install an **Edit** menu, so the standard +//! Cut/Copy/Paste/Select-All key equivalents are never delivered down the +//! responder chain. That doesn't matter for pure-egui content (egui handles its +//! own clipboard), but the native Web Page View embeds a `WKWebView` child whose +//! copy/paste rely on macOS routing `cmd+C`/`cmd+V` to it via `performKeyEquivalent:`. +//! +//! Installing an Edit menu whose items target `nil` (the default) makes the +//! responder chain deliver `copy:`/`paste:`/etc. to whichever view is first +//! responder — i.e. the focused webview — so selecting text and pressing +//! `cmd+C` works. +#![allow(unsafe_code)] // Thin Objective-C bridge to build an NSMenu; see module docs. + +#[cfg(target_os = "macos")] +pub fn install_edit_menu() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { install_edit_menu_impl() }); +} + +#[cfg(not(target_os = "macos"))] +pub fn install_edit_menu() {} + +#[cfg(target_os = "macos")] +unsafe fn install_edit_menu_impl() { + use objc2::runtime::{AnyObject, Sel}; + use objc2::{class, msg_send, sel}; + use std::ffi::CString; + + // All objects below are added to the app's main menu, which lives for the whole + // process, so the menu retains them; we intentionally don't balance the +1 from + // alloc/init. NSStrings are autoreleased and only need to outlive the calls here. + unsafe fn ns_string(s: &str) -> *mut AnyObject { + let cstr = CString::new(s).unwrap_or_default(); + unsafe { msg_send![class!(NSString), stringWithUTF8String: cstr.as_ptr()] } + } + + unsafe fn add_item(menu: *mut AnyObject, title: &str, action: Sel, key: &str) { + unsafe { + let alloc: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; + let item: *mut AnyObject = msg_send![ + alloc, + initWithTitle: ns_string(title), + action: action, + keyEquivalent: ns_string(key) + ]; + let _: () = msg_send![menu, addItem: item]; + } + } + + unsafe { + // The app (and main thread) already exist by the time the first frame runs. + let app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + if app.is_null() { + return; + } + + let mut main_menu: *mut AnyObject = msg_send![app, mainMenu]; + if main_menu.is_null() { + let alloc: *mut AnyObject = msg_send![class!(NSMenu), alloc]; + main_menu = msg_send![alloc, init]; + let _: () = msg_send![app, setMainMenu: main_menu]; + } + + let edit_alloc: *mut AnyObject = msg_send![class!(NSMenu), alloc]; + let edit_menu: *mut AnyObject = msg_send![edit_alloc, initWithTitle: ns_string("Edit")]; + + add_item(edit_menu, "Undo", sel!(undo:), "z"); + add_item(edit_menu, "Redo", sel!(redo:), "Z"); + add_item(edit_menu, "Cut", sel!(cut:), "x"); + add_item(edit_menu, "Copy", sel!(copy:), "c"); + add_item(edit_menu, "Paste", sel!(paste:), "v"); + add_item(edit_menu, "Select All", sel!(selectAll:), "a"); + + let item_alloc: *mut AnyObject = msg_send![class!(NSMenuItem), alloc]; + let edit_item: *mut AnyObject = msg_send![item_alloc, init]; + let _: () = msg_send![edit_item, setSubmenu: edit_menu]; + let _: () = msg_send![main_menu, addItem: edit_item]; + } +} diff --git a/dimos/src/viewer.rs b/dimos/src/viewer.rs index 729ecbc9f694..ef2dc9981d02 100644 --- a/dimos/src/viewer.rs +++ b/dimos/src/viewer.rs @@ -4,11 +4,11 @@ //! - Click-to-navigate: click any entity with a 3D position → sends click event via WebSocket //! - WASD keyboard teleop: click overlay to engage, then WASD publishes Twist via WebSocket -use std::rc::Rc; use std::cell::RefCell; +use std::rc::Rc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use dimos_viewer::interaction::{KeyboardHandler, WsPublisher}; +use dimos_viewer::interaction::{KeyboardHandler, WsCommand, WsPublisher}; use rerun::external::{eframe, egui, re_log, re_memory, re_viewer}; #[global_allocator] @@ -28,6 +28,35 @@ const RAPID_CLICK_THRESHOLD: usize = 5; struct DimosApp { inner: re_viewer::App, keyboard: KeyboardHandler, + ws_publisher: WsPublisher, +} + +impl DimosApp { + fn handle_ws_commands(&mut self) { + while let Some(command) = self.ws_publisher.try_recv_command() { + if let Err(err) = command.validate() { + re_log::warn!("Ignoring invalid websocket command: {err}"); + continue; + } + + match command { + WsCommand::OpenWebPageView { + panel_id, + title, + url, + show_navigation_controls, + } => { + self.inner + .open_or_update_web_page_view(re_viewer::WebPageViewRequest { + panel_id, + title, + url, + show_navigation_controls, + }); + } + } + } + } } impl eframe::App for DimosApp { @@ -35,22 +64,34 @@ impl eframe::App for DimosApp { /// re_viewer::App drains log_receivers / ingests messages here, so we MUST /// forward — otherwise the viewer's data pipeline stalls. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + // Idempotent; installs the macOS Edit menu on the first frame so that the + // native Web Page View can receive cmd+C / cmd+V via the responder chain. + dimos_viewer::macos_menu::install_edit_menu(); self.inner.logic(ctx, frame); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.keyboard.process(ui.ctx()); self.keyboard.draw_overlay(ui.ctx()); + self.handle_ws_commands(); self.inner.ui(ui, frame); } - fn save(&mut self, storage: &mut dyn eframe::Storage) { self.inner.save(storage); } + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.inner.save(storage); + } - fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] { self.inner.clear_color(visuals) } + fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] { + self.inner.clear_color(visuals) + } - fn persist_egui_memory(&self) -> bool { self.inner.persist_egui_memory() } + fn persist_egui_memory(&self) -> bool { + self.inner.persist_egui_memory() + } - fn auto_save_interval(&self) -> Duration { self.inner.auto_save_interval() } + fn auto_save_interval(&self) -> Duration { + self.inner.auto_save_interval() + } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.inner.raw_input_hook(ctx, raw_input); @@ -83,14 +124,13 @@ fn main() -> Result<(), Box> { // Connect WebSocket publisher for click/keyboard events let ws_publisher = WsPublisher::connect(ws_url.clone()); if debug { - eprintln!("[DIMOS_DEBUG] WebSocket client target: {ws_url}"); + re_log::debug!("[DIMOS_DEBUG] WebSocket client target: {ws_url}"); } let keyboard_handler_ws = ws_publisher.clone(); + let command_ws = ws_publisher.clone(); - let last_click_time = Rc::new(RefCell::new( - Instant::now() - Duration::from_secs(10) - )); + let last_click_time = Rc::new(RefCell::new(Instant::now() - Duration::from_secs(10))); let rapid_click_count = Rc::new(RefCell::new(0usize)); // Plain click (no Ctrl required) fires nav goal on any entity with a 3D position @@ -142,7 +182,10 @@ fn main() -> Result<(), Box> { } re_log::debug!( "Click event published: entity={}, pos=({:.2}, {:.2}, {:.2})", - entity_path, pos.x, pos.y, pos.z + entity_path, + pos.x, + pos.y, + pos.z ); } re_viewer::SelectionChangeItem::Entity { position: None, .. } => { @@ -164,17 +207,27 @@ fn main() -> Result<(), Box> { if debug { if let Some(ref connect) = parsed.connect { match connect.as_deref() { - Some(url) => eprintln!("[DIMOS_DEBUG] gRPC connecting to: {url}"), - None => eprintln!("[DIMOS_DEBUG] gRPC connecting to default (port {})", parsed.port), + Some(url) => re_log::debug!("[DIMOS_DEBUG] gRPC connecting to: {url}"), + None => re_log::debug!( + "[DIMOS_DEBUG] gRPC connecting to default (port {})", + parsed.port + ), } } else { - eprintln!("[DIMOS_DEBUG] gRPC: starting local server on port {}", parsed.port); + re_log::debug!( + "[DIMOS_DEBUG] gRPC: starting local server on port {}", + parsed.port + ); } } let wrapper: rerun::AppWrapper = Box::new(move |app| { let keyboard = KeyboardHandler::new(keyboard_handler_ws.clone()); - Ok(Box::new(DimosApp { inner: app, keyboard })) + Ok(Box::new(DimosApp { + inner: app, + keyboard, + ws_publisher: command_ws.clone(), + })) }); let exit_code = rerun::run_with_app_wrapper( diff --git a/docs/content/reference/types/views.md b/docs/content/reference/types/views.md index 46f210af41d2..4058b9255c7b 100644 --- a/docs/content/reference/types/views.md +++ b/docs/content/reference/types/views.md @@ -18,4 +18,5 @@ Views are the panels shown in the viewer's viewport and the primary means of ins * [`TextDocumentView`](views/text_document_view.md): A view of a single text document, for use with [`archetypes.TextDocument`](https://rerun.io/docs/reference/types/archetypes/text_document). * [`TextLogView`](views/text_log_view.md): A view of a text log, for use with [`archetypes.TextLog`](https://rerun.io/docs/reference/types/archetypes/text_log). * [`TimeSeriesView`](views/time_series_view.md): A time series view for scalars over time, for use with [`archetypes.Scalars`](https://rerun.io/docs/reference/types/archetypes/scalars). +* [`WebPageView`](views/web_page_view.md): A native-only view that embeds a configured web page. diff --git a/docs/content/reference/types/views/.gitattributes b/docs/content/reference/types/views/.gitattributes index b08d56045cc4..fc0234b7d24a 100644 --- a/docs/content/reference/types/views/.gitattributes +++ b/docs/content/reference/types/views/.gitattributes @@ -12,3 +12,4 @@ tensor_view.md linguist-generated=true text_document_view.md linguist-generated=true text_log_view.md linguist-generated=true time_series_view.md linguist-generated=true +web_page_view.md linguist-generated=true diff --git a/docs/content/reference/types/views/web_page_view.md b/docs/content/reference/types/views/web_page_view.md new file mode 100644 index 000000000000..692ba981d87c --- /dev/null +++ b/docs/content/reference/types/views/web_page_view.md @@ -0,0 +1,30 @@ +--- +title: "WebPageView" +--- + + +⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** +A native-only view that embeds a configured web page. + +Web Page View is unsupported in the web viewer. Native support depends on the +operating-system webview runtime provided through `wry`: `WebView2` on Windows, +`WebKit` on macOS, and `WebKitGTK` on Linux. Linux child webviews may also depend +on the active display-server integration; the direct child-window path is +X11-oriented, while Wayland-capable embedding requires a GTK container path. + +## Properties + +### `config` +Configuration for the web page to embed. + +* `url`: The initial URL to load. +* `show_navigation_controls`: Whether browser navigation controls should be shown. + +## API reference links + * 🐍 [Python API docs for `WebPageView`](https://ref.rerun.io/docs/python/stable/common/blueprint_views?speculative-link#rerun.blueprint.views.WebPageView) + + +## Visualized archetypes + +* [`WebPageViewConfig`](../archetypes/web_page_view_config.md) + diff --git a/docs/websockets.md b/docs/websockets.md index ee0c5e7f5edc..840a76e1ff3e 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,4 +1,4 @@ -# dimos-viewer WebSocket Event Stream +# Dimos-viewer WebSocket event stream When `dimos-viewer` is started with `--connect`, LCM multicast is not available (LCM uses UDP multicast which is limited to the local machine or subnet). Instead, diff --git a/rerun_cpp/src/rerun/blueprint/archetypes.hpp b/rerun_cpp/src/rerun/blueprint/archetypes.hpp index d3cb29c14b38..8d375da14e18 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes.hpp +++ b/rerun_cpp/src/rerun/blueprint/archetypes.hpp @@ -37,3 +37,4 @@ #include "blueprint/archetypes/visible_time_ranges.hpp" #include "blueprint/archetypes/visual_bounds2d.hpp" #include "blueprint/archetypes/visualizer_instruction.hpp" +#include "blueprint/archetypes/web_page_view_config.hpp" diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/.gitattributes b/rerun_cpp/src/rerun/blueprint/archetypes/.gitattributes index c324eb6a8ac9..a7a0deed6803 100644 --- a/rerun_cpp/src/rerun/blueprint/archetypes/.gitattributes +++ b/rerun_cpp/src/rerun/blueprint/archetypes/.gitattributes @@ -71,3 +71,5 @@ visual_bounds2d.cpp linguist-generated=true visual_bounds2d.hpp linguist-generated=true visualizer_instruction.cpp linguist-generated=true visualizer_instruction.hpp linguist-generated=true +web_page_view_config.cpp linguist-generated=true +web_page_view_config.hpp linguist-generated=true diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.cpp b/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.cpp new file mode 100644 index 000000000000..6843bf8f75c8 --- /dev/null +++ b/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.cpp @@ -0,0 +1,65 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/cpp/mod.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs". + +#include "web_page_view_config.hpp" + +#include "../../collection_adapter_builtins.hpp" + +namespace rerun::blueprint::archetypes { + WebPageViewConfig WebPageViewConfig::clear_fields() { + auto archetype = WebPageViewConfig(); + archetype.url = + ComponentBatch::empty(Descriptor_url) + .value_or_throw(); + archetype.show_navigation_controls = + ComponentBatch::empty( + Descriptor_show_navigation_controls + ) + .value_or_throw(); + return archetype; + } + + Collection WebPageViewConfig::columns(const Collection& lengths_) { + std::vector columns; + columns.reserve(2); + if (url.has_value()) { + columns.push_back(url.value().partitioned(lengths_).value_or_throw()); + } + if (show_navigation_controls.has_value()) { + columns.push_back(show_navigation_controls.value().partitioned(lengths_).value_or_throw( + )); + } + return columns; + } + + Collection WebPageViewConfig::columns() { + if (url.has_value()) { + return columns(std::vector(url.value().length(), 1)); + } + if (show_navigation_controls.has_value()) { + return columns(std::vector(show_navigation_controls.value().length(), 1)); + } + return Collection(); + } +} // namespace rerun::blueprint::archetypes + +namespace rerun { + + Result> + AsComponents::as_batches( + const blueprint::archetypes::WebPageViewConfig& archetype + ) { + using namespace blueprint::archetypes; + std::vector cells; + cells.reserve(2); + + if (archetype.url.has_value()) { + cells.push_back(archetype.url.value()); + } + if (archetype.show_navigation_controls.has_value()) { + cells.push_back(archetype.show_navigation_controls.value()); + } + + return rerun::take_ownership(std::move(cells)); + } +} // namespace rerun diff --git a/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.hpp b/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.hpp new file mode 100644 index 000000000000..e186a0e5cc4b --- /dev/null +++ b/rerun_cpp/src/rerun/blueprint/archetypes/web_page_view_config.hpp @@ -0,0 +1,119 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/cpp/mod.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs". + +#pragma once + +#include "../../blueprint/components/show_navigation_controls.hpp" +#include "../../blueprint/components/web_page_url.hpp" +#include "../../collection.hpp" +#include "../../component_batch.hpp" +#include "../../component_column.hpp" +#include "../../result.hpp" + +#include +#include +#include +#include + +namespace rerun::blueprint::archetypes { + /// **Archetype**: Configuration for the Web Page View. + /// + /// ⚠ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + /// + struct WebPageViewConfig { + /// The initial URL to load. + std::optional url; + + /// Whether browser navigation controls should be shown. + /// + /// Defaults to true. + std::optional show_navigation_controls; + + public: + /// The name of the archetype as used in `ComponentDescriptor`s. + static constexpr const char ArchetypeName[] = + "rerun.blueprint.archetypes.WebPageViewConfig"; + + /// `ComponentDescriptor` for the `url` field. + static constexpr auto Descriptor_url = ComponentDescriptor( + ArchetypeName, "WebPageViewConfig:url", + Loggable::ComponentType + ); + /// `ComponentDescriptor` for the `show_navigation_controls` field. + static constexpr auto Descriptor_show_navigation_controls = ComponentDescriptor( + ArchetypeName, "WebPageViewConfig:show_navigation_controls", + Loggable::ComponentType + ); + + public: + WebPageViewConfig() = default; + WebPageViewConfig(WebPageViewConfig&& other) = default; + WebPageViewConfig(const WebPageViewConfig& other) = default; + WebPageViewConfig& operator=(const WebPageViewConfig& other) = default; + WebPageViewConfig& operator=(WebPageViewConfig&& other) = default; + + explicit WebPageViewConfig(rerun::blueprint::components::WebPageUrl _url) + : url(ComponentBatch::from_loggable(std::move(_url), Descriptor_url).value_or_throw()) { + } + + /// Update only some specific fields of a `WebPageViewConfig`. + static WebPageViewConfig update_fields() { + return WebPageViewConfig(); + } + + /// Clear all the fields of a `WebPageViewConfig`. + static WebPageViewConfig clear_fields(); + + /// The initial URL to load. + WebPageViewConfig with_url(const rerun::blueprint::components::WebPageUrl& _url) && { + url = ComponentBatch::from_loggable(_url, Descriptor_url).value_or_throw(); + return std::move(*this); + } + + /// Whether browser navigation controls should be shown. + /// + /// Defaults to true. + WebPageViewConfig with_show_navigation_controls( + const rerun::blueprint::components::ShowNavigationControls& _show_navigation_controls + ) && { + show_navigation_controls = ComponentBatch::from_loggable( + _show_navigation_controls, + Descriptor_show_navigation_controls + ) + .value_or_throw(); + return std::move(*this); + } + + /// Partitions the component data into multiple sub-batches. + /// + /// Specifically, this transforms the existing `ComponentBatch` data into `ComponentColumn`s + /// instead, via `ComponentBatch::partitioned`. + /// + /// This makes it possible to use `RecordingStream::send_columns` to send columnar data directly into Rerun. + /// + /// The specified `lengths` must sum to the total length of the component batch. + Collection columns(const Collection& lengths_); + + /// Partitions the component data into unit-length sub-batches. + /// + /// This is semantically similar to calling `columns` with `std::vector(n, 1)`, + /// where `n` is automatically guessed. + Collection columns(); + }; + +} // namespace rerun::blueprint::archetypes + +namespace rerun { + /// \private + template + struct AsComponents; + + /// \private + template <> + struct AsComponents { + /// Serialize all set component batches. + static Result> as_batches( + const blueprint::archetypes::WebPageViewConfig& archetype + ); + }; +} // namespace rerun diff --git a/rerun_cpp/src/rerun/blueprint/components.hpp b/rerun_cpp/src/rerun/blueprint/components.hpp index a2c377e4c033..dd673341fb6d 100644 --- a/rerun_cpp/src/rerun/blueprint/components.hpp +++ b/rerun_cpp/src/rerun/blueprint/components.hpp @@ -38,6 +38,7 @@ #include "blueprint/components/root_container.hpp" #include "blueprint/components/row_share.hpp" #include "blueprint/components/selected_columns.hpp" +#include "blueprint/components/show_navigation_controls.hpp" #include "blueprint/components/tensor_dimension_index_slider.hpp" #include "blueprint/components/text_log_column.hpp" #include "blueprint/components/time_int.hpp" @@ -54,4 +55,5 @@ #include "blueprint/components/visualizer_component_mapping.hpp" #include "blueprint/components/visualizer_instruction_id.hpp" #include "blueprint/components/visualizer_type.hpp" +#include "blueprint/components/web_page_url.hpp" #include "blueprint/components/zoom_level.hpp" diff --git a/rerun_cpp/src/rerun/blueprint/components/.gitattributes b/rerun_cpp/src/rerun/blueprint/components/.gitattributes index d8df7ee127ad..bc05d488cf36 100644 --- a/rerun_cpp/src/rerun/blueprint/components/.gitattributes +++ b/rerun_cpp/src/rerun/blueprint/components/.gitattributes @@ -47,6 +47,7 @@ query_expression.hpp linguist-generated=true root_container.hpp linguist-generated=true row_share.hpp linguist-generated=true selected_columns.hpp linguist-generated=true +show_navigation_controls.hpp linguist-generated=true tensor_dimension_index_slider.hpp linguist-generated=true text_log_column.hpp linguist-generated=true time_int.hpp linguist-generated=true @@ -64,4 +65,5 @@ visual_bounds2d.hpp linguist-generated=true visualizer_component_mapping.hpp linguist-generated=true visualizer_instruction_id.hpp linguist-generated=true visualizer_type.hpp linguist-generated=true +web_page_url.hpp linguist-generated=true zoom_level.hpp linguist-generated=true diff --git a/rerun_cpp/src/rerun/blueprint/components/show_navigation_controls.hpp b/rerun_cpp/src/rerun/blueprint/components/show_navigation_controls.hpp new file mode 100644 index 000000000000..3744e19bea1f --- /dev/null +++ b/rerun_cpp/src/rerun/blueprint/components/show_navigation_controls.hpp @@ -0,0 +1,80 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/cpp/mod.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs". + +#pragma once + +#include "../../datatypes/bool.hpp" +#include "../../result.hpp" + +#include +#include + +namespace rerun::blueprint::components { + /// **Component**: Whether browser navigation controls should be shown in the Web Page View. + /// + /// ⚠ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + /// + struct ShowNavigationControls { + rerun::datatypes::Bool show_navigation_controls; + + public: + ShowNavigationControls() = default; + + ShowNavigationControls(rerun::datatypes::Bool show_navigation_controls_) + : show_navigation_controls(show_navigation_controls_) {} + + ShowNavigationControls& operator=(rerun::datatypes::Bool show_navigation_controls_) { + show_navigation_controls = show_navigation_controls_; + return *this; + } + + ShowNavigationControls(bool value_) : show_navigation_controls(value_) {} + + ShowNavigationControls& operator=(bool value_) { + show_navigation_controls = value_; + return *this; + } + + /// Cast to the underlying Bool datatype + operator rerun::datatypes::Bool() const { + return show_navigation_controls; + } + }; +} // namespace rerun::blueprint::components + +namespace rerun { + static_assert( + sizeof(rerun::datatypes::Bool) == sizeof(blueprint::components::ShowNavigationControls) + ); + + /// \private + template <> + struct Loggable { + static constexpr std::string_view ComponentType = + "rerun.blueprint.components.ShowNavigationControls"; + + /// Returns the arrow data type this type corresponds to. + static const std::shared_ptr& arrow_datatype() { + return Loggable::arrow_datatype(); + } + + /// Serializes an array of `rerun::blueprint:: components::ShowNavigationControls` into an arrow array. + static Result> to_arrow( + const blueprint::components::ShowNavigationControls* instances, size_t num_instances + ) { + if (num_instances == 0) { + return Loggable::to_arrow(nullptr, 0); + } else if (instances == nullptr) { + return rerun::Error( + ErrorCode::UnexpectedNullArgument, + "Passed array instances is null when num_elements> 0." + ); + } else { + return Loggable::to_arrow( + &instances->show_navigation_controls, + num_instances + ); + } + } + }; +} // namespace rerun diff --git a/rerun_cpp/src/rerun/blueprint/components/web_page_url.hpp b/rerun_cpp/src/rerun/blueprint/components/web_page_url.hpp new file mode 100644 index 000000000000..dad05982772e --- /dev/null +++ b/rerun_cpp/src/rerun/blueprint/components/web_page_url.hpp @@ -0,0 +1,75 @@ +// DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/cpp/mod.rs +// Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs". + +#pragma once + +#include "../../datatypes/utf8.hpp" +#include "../../result.hpp" + +#include +#include +#include +#include + +namespace rerun::blueprint::components { + /// **Component**: The initial URL to load in a Web Page View. + /// + /// ⚠ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + /// + struct WebPageUrl { + rerun::datatypes::Utf8 url; + + public: + WebPageUrl() = default; + + WebPageUrl(rerun::datatypes::Utf8 url_) : url(std::move(url_)) {} + + WebPageUrl& operator=(rerun::datatypes::Utf8 url_) { + url = std::move(url_); + return *this; + } + + WebPageUrl(std::string value_) : url(std::move(value_)) {} + + WebPageUrl& operator=(std::string value_) { + url = std::move(value_); + return *this; + } + + /// Cast to the underlying Utf8 datatype + operator rerun::datatypes::Utf8() const { + return url; + } + }; +} // namespace rerun::blueprint::components + +namespace rerun { + static_assert(sizeof(rerun::datatypes::Utf8) == sizeof(blueprint::components::WebPageUrl)); + + /// \private + template <> + struct Loggable { + static constexpr std::string_view ComponentType = "rerun.blueprint.components.WebPageUrl"; + + /// Returns the arrow data type this type corresponds to. + static const std::shared_ptr& arrow_datatype() { + return Loggable::arrow_datatype(); + } + + /// Serializes an array of `rerun::blueprint:: components::WebPageUrl` into an arrow array. + static Result> to_arrow( + const blueprint::components::WebPageUrl* instances, size_t num_instances + ) { + if (num_instances == 0) { + return Loggable::to_arrow(nullptr, 0); + } else if (instances == nullptr) { + return rerun::Error( + ErrorCode::UnexpectedNullArgument, + "Passed array instances is null when num_elements> 0." + ); + } else { + return Loggable::to_arrow(&instances->url, num_instances); + } + } + }; +} // namespace rerun diff --git a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py index 71ea8123c1aa..9b633360af1b 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py @@ -44,6 +44,7 @@ TimeAxis as TimeAxis, VisibleTimeRanges as VisibleTimeRanges, VisualBounds2D as VisualBounds2D, + WebPageViewConfig as WebPageViewConfig, ) from .components import ( BackgroundKind as BackgroundKind, @@ -70,5 +71,6 @@ TextDocumentView as TextDocumentView, TextLogView as TextLogView, TimeSeriesView as TimeSeriesView, + WebPageView as WebPageView, ) from .visualizers import VisualizableArchetype as VisualizableArchetype, Visualizer as Visualizer diff --git a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/.gitattributes b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/.gitattributes index 73782fdeccc1..1ceac83a030b 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/.gitattributes +++ b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/.gitattributes @@ -37,3 +37,4 @@ viewport_blueprint.py linguist-generated=true visible_time_ranges.py linguist-generated=true visual_bounds2d.py linguist-generated=true visualizer_instruction.py linguist-generated=true +web_page_view_config.py linguist-generated=true diff --git a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/__init__.py index 7e961d129f7f..a6357d0f4f14 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/__init__.py @@ -37,6 +37,7 @@ from .visible_time_ranges import VisibleTimeRanges from .visual_bounds2d import VisualBounds2D from .visualizer_instruction import VisualizerInstruction +from .web_page_view_config import WebPageViewConfig __all__ = [ "ActiveVisualizers", @@ -74,4 +75,5 @@ "VisibleTimeRanges", "VisualBounds2D", "VisualizerInstruction", + "WebPageViewConfig", ] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/archetypes/web_page_view_config.py b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/web_page_view_config.py new file mode 100644 index 000000000000..771405a193a2 --- /dev/null +++ b/rerun_py/rerun_sdk/rerun/blueprint/archetypes/web_page_view_config.py @@ -0,0 +1,154 @@ +# DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/python/mod.rs +# Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/archetypes/web_page_view_config.fbs". + +# You can extend this class by creating a "WebPageViewConfigExt" class in "web_page_view_config_ext.py". + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from attrs import define, field + +from ..._baseclasses import ( + Archetype, + ComponentDescriptor, +) +from ...blueprint import components as blueprint_components +from ...error_utils import catch_and_log_exceptions + +if TYPE_CHECKING: + from ... import datatypes + +__all__ = ["WebPageViewConfig"] + + +@define(str=False, repr=False, init=False) +class WebPageViewConfig(Archetype): + """ + **Archetype**: Configuration for the Web Page View. + + ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + """ + + NAME: ClassVar[str] = "rerun.blueprint.archetypes.WebPageViewConfig" + + def __init__( + self: Any, url: datatypes.Utf8Like, *, show_navigation_controls: datatypes.BoolLike | None = None + ) -> None: + """ + Create a new instance of the WebPageViewConfig archetype. + + Parameters + ---------- + url: + The initial URL to load. + show_navigation_controls: + Whether browser navigation controls should be shown. + + Defaults to true. + + """ + + # You can define your own __init__ function as a member of WebPageViewConfigExt in web_page_view_config_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(url=url, show_navigation_controls=show_navigation_controls) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + url=None, + show_navigation_controls=None, + ) + + @classmethod + def _clear(cls) -> WebPageViewConfig: + """Produce an empty WebPageViewConfig, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + + @classmethod + def from_fields( + cls, + *, + clear_unset: bool = False, + url: datatypes.Utf8Like | None = None, + show_navigation_controls: datatypes.BoolLike | None = None, + ) -> WebPageViewConfig: + """ + Update only some specific fields of a `WebPageViewConfig`. + + Parameters + ---------- + clear_unset: + If true, all unspecified fields will be explicitly cleared. + url: + The initial URL to load. + show_navigation_controls: + Whether browser navigation controls should be shown. + + Defaults to true. + + """ + + inst = cls.__new__(cls) + with catch_and_log_exceptions(context=cls.__name__): + kwargs = { + "url": url, + "show_navigation_controls": show_navigation_controls, + } + + if clear_unset: + kwargs = {k: v if v is not None else [] for k, v in kwargs.items()} # type: ignore[misc] + + inst.__attrs_init__(**kwargs) + return inst + + inst.__attrs_clear__() + return inst + + @classmethod + def cleared(cls) -> WebPageViewConfig: + """Clear all the fields of a `WebPageViewConfig`.""" + return cls.from_fields(clear_unset=True) + + @staticmethod + def descriptor_url() -> ComponentDescriptor: + return ComponentDescriptor( + "WebPageViewConfig:url", + archetype=WebPageViewConfig.NAME, + component_type=blueprint_components.WebPageUrlBatch._COMPONENT_TYPE, + ) + + @staticmethod + def descriptor_show_navigation_controls() -> ComponentDescriptor: + return ComponentDescriptor( + "WebPageViewConfig:show_navigation_controls", + archetype=WebPageViewConfig.NAME, + component_type=blueprint_components.ShowNavigationControlsBatch._COMPONENT_TYPE, + ) + + url: blueprint_components.WebPageUrlBatch | None = field( + metadata={"component": True}, + default=None, + converter=blueprint_components.WebPageUrlBatch._converter, # type: ignore[misc] + ) + # The initial URL to load. + # + # (Docstring intentionally commented out to hide this field from the docs) + + show_navigation_controls: blueprint_components.ShowNavigationControlsBatch | None = field( + metadata={"component": True}, + default=None, + converter=blueprint_components.ShowNavigationControlsBatch._converter, # type: ignore[misc] + ) + # Whether browser navigation controls should be shown. + # + # Defaults to true. + # + # (Docstring intentionally commented out to hide this field from the docs) + + __str__ = Archetype.__str__ + __repr__ = Archetype.__repr__ # type: ignore[assignment] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/components/.gitattributes b/rerun_py/rerun_sdk/rerun/blueprint/components/.gitattributes index 80919b9b1ae0..659d430f12c5 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/components/.gitattributes +++ b/rerun_py/rerun_sdk/rerun/blueprint/components/.gitattributes @@ -38,6 +38,7 @@ query_expression.py linguist-generated=true root_container.py linguist-generated=true row_share.py linguist-generated=true selected_columns.py linguist-generated=true +show_navigation_controls.py linguist-generated=true tensor_dimension_index_slider.py linguist-generated=true text_log_column.py linguist-generated=true time_int.py linguist-generated=true @@ -54,4 +55,5 @@ visual_bounds2d.py linguist-generated=true visualizer_component_mapping.py linguist-generated=true visualizer_instruction_id.py linguist-generated=true visualizer_type.py linguist-generated=true +web_page_url.py linguist-generated=true zoom_level.py linguist-generated=true diff --git a/rerun_py/rerun_sdk/rerun/blueprint/components/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/components/__init__.py index 4a999fcefc59..8ebc6f5b7cab 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/components/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/components/__init__.py @@ -38,6 +38,7 @@ from .root_container import RootContainer, RootContainerBatch from .row_share import RowShare, RowShareBatch from .selected_columns import SelectedColumns, SelectedColumnsBatch +from .show_navigation_controls import ShowNavigationControls, ShowNavigationControlsBatch from .tensor_dimension_index_slider import TensorDimensionIndexSlider, TensorDimensionIndexSliderBatch from .text_log_column import TextLogColumn, TextLogColumnBatch from .time_int import TimeInt, TimeIntBatch @@ -54,6 +55,7 @@ from .visualizer_component_mapping import VisualizerComponentMapping, VisualizerComponentMappingBatch from .visualizer_instruction_id import VisualizerInstructionId, VisualizerInstructionIdBatch from .visualizer_type import VisualizerType, VisualizerTypeBatch +from .web_page_url import WebPageUrl, WebPageUrlBatch from .zoom_level import ZoomLevel, ZoomLevelBatch __all__ = [ @@ -149,6 +151,8 @@ "RowShareBatch", "SelectedColumns", "SelectedColumnsBatch", + "ShowNavigationControls", + "ShowNavigationControlsBatch", "TensorDimensionIndexSlider", "TensorDimensionIndexSliderBatch", "TextLogColumn", @@ -183,6 +187,8 @@ "VisualizerInstructionIdBatch", "VisualizerType", "VisualizerTypeBatch", + "WebPageUrl", + "WebPageUrlBatch", "ZoomLevel", "ZoomLevelBatch", ] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/components/show_navigation_controls.py b/rerun_py/rerun_sdk/rerun/blueprint/components/show_navigation_controls.py new file mode 100644 index 000000000000..63a362e220cc --- /dev/null +++ b/rerun_py/rerun_sdk/rerun/blueprint/components/show_navigation_controls.py @@ -0,0 +1,35 @@ +# DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/python/mod.rs +# Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/show_navigation_controls.fbs". + +# You can extend this class by creating a "ShowNavigationControlsExt" class in "show_navigation_controls_ext.py". + +from __future__ import annotations + +from ... import datatypes +from ..._baseclasses import ( + ComponentBatchMixin, + ComponentMixin, +) + +__all__ = ["ShowNavigationControls", "ShowNavigationControlsBatch"] + + +class ShowNavigationControls(datatypes.Bool, ComponentMixin): + """ + **Component**: Whether browser navigation controls should be shown in the Web Page View. + + ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + """ + + _BATCH_TYPE = None + # You can define your own __init__ function as a member of ShowNavigationControlsExt in show_navigation_controls_ext.py + + # Note: there are no fields here because ShowNavigationControls delegates to datatypes.Bool + + +class ShowNavigationControlsBatch(datatypes.BoolBatch, ComponentBatchMixin): + _COMPONENT_TYPE: str = "rerun.blueprint.components.ShowNavigationControls" + + +# This is patched in late to avoid circular dependencies. +ShowNavigationControls._BATCH_TYPE = ShowNavigationControlsBatch # type: ignore[assignment] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/components/web_page_url.py b/rerun_py/rerun_sdk/rerun/blueprint/components/web_page_url.py new file mode 100644 index 000000000000..ead0b0a8b534 --- /dev/null +++ b/rerun_py/rerun_sdk/rerun/blueprint/components/web_page_url.py @@ -0,0 +1,35 @@ +# DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/python/mod.rs +# Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/components/web_page_url.fbs". + +# You can extend this class by creating a "WebPageUrlExt" class in "web_page_url_ext.py". + +from __future__ import annotations + +from ... import datatypes +from ..._baseclasses import ( + ComponentBatchMixin, + ComponentMixin, +) + +__all__ = ["WebPageUrl", "WebPageUrlBatch"] + + +class WebPageUrl(datatypes.Utf8, ComponentMixin): + """ + **Component**: The initial URL to load in a Web Page View. + + ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + """ + + _BATCH_TYPE = None + # You can define your own __init__ function as a member of WebPageUrlExt in web_page_url_ext.py + + # Note: there are no fields here because WebPageUrl delegates to datatypes.Utf8 + + +class WebPageUrlBatch(datatypes.Utf8Batch, ComponentBatchMixin): + _COMPONENT_TYPE: str = "rerun.blueprint.components.WebPageUrl" + + +# This is patched in late to avoid circular dependencies. +WebPageUrl._BATCH_TYPE = WebPageUrlBatch # type: ignore[assignment] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/views/.gitattributes b/rerun_py/rerun_sdk/rerun/blueprint/views/.gitattributes index 1cf2a57418bd..a95245ffcf50 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/views/.gitattributes +++ b/rerun_py/rerun_sdk/rerun/blueprint/views/.gitattributes @@ -13,3 +13,4 @@ tensor_view.py linguist-generated=true text_document_view.py linguist-generated=true text_log_view.py linguist-generated=true time_series_view.py linguist-generated=true +web_page_view.py linguist-generated=true diff --git a/rerun_py/rerun_sdk/rerun/blueprint/views/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/views/__init__.py index d800db82767f..c476baef2178 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/views/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/views/__init__.py @@ -13,6 +13,7 @@ from .text_document_view import TextDocumentView from .text_log_view import TextLogView from .time_series_view import TimeSeriesView +from .web_page_view import WebPageView __all__ = [ "BarChartView", @@ -26,4 +27,5 @@ "TextDocumentView", "TextLogView", "TimeSeriesView", + "WebPageView", ] diff --git a/rerun_py/rerun_sdk/rerun/blueprint/views/web_page_view.py b/rerun_py/rerun_sdk/rerun/blueprint/views/web_page_view.py new file mode 100644 index 000000000000..ee5e4197be02 --- /dev/null +++ b/rerun_py/rerun_sdk/rerun/blueprint/views/web_page_view.py @@ -0,0 +1,106 @@ +# DO NOT EDIT! This file was auto-generated by crates/build/re_types_builder/src/codegen/python/mod.rs +# Based on "crates/store/re_sdk_types/definitions/rerun/blueprint/views/web_page.fbs". + +from __future__ import annotations + +from typing import TYPE_CHECKING + +__all__ = ["WebPageView"] + + +from .. import archetypes as blueprint_archetypes +from ..api import View, ViewContentsLike, VisualizerLike + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from ... import datatypes + from ..._baseclasses import ( + AsComponents, + DescribedComponentBatch, + ) + from ...datatypes import EntityPathLike, Utf8Like + + +class WebPageView(View): + """ + **View**: A native-only view that embeds a configured web page. + + Web Page View is unsupported in the web viewer. Native support depends on the + operating-system webview runtime provided through `wry`: `WebView2` on Windows, + `WebKit` on macOS, and `WebKitGTK` on Linux. Linux child webviews may also depend + on the active display-server integration; the direct child-window path is + X11-oriented, while Wayland-capable embedding requires a GTK container path. + + ⚠️ **This type is _unstable_ and may change significantly in a way that the data won't be backwards compatible.** + """ + + def __init__( + self, + *, + origin: EntityPathLike = "/", + contents: ViewContentsLike = "$origin/**", + name: Utf8Like | None = None, + visible: datatypes.BoolLike | None = None, + defaults: Iterable[AsComponents | Iterable[DescribedComponentBatch]] | None = None, + overrides: Mapping[EntityPathLike, VisualizerLike | Iterable[VisualizerLike]] | None = None, + config: blueprint_archetypes.WebPageViewConfig | None = None, + ) -> None: + """ + Construct a blueprint for a new WebPageView view. + + Parameters + ---------- + origin: + The `EntityPath` to use as the origin of this view. + All other entities will be transformed to be displayed relative to this origin. + contents: + The contents of the view specified as a query expression. + This is either a single expression, or a list of multiple expressions. + See [rerun.blueprint.archetypes.ViewContents][]. + name: + The display name of the view. + visible: + Whether this view is visible. + + Defaults to true if not specified. + defaults: + List of archetypes or (described) component batches to add to the view. + When an archetype in the view is missing a component included in this set, + the value of default will be used instead of the normal fallback for the visualizer. + + Note that an archetype's required components typically don't have any effect. + It is recommended to use the archetype's `from_fields` method instead and only specify the fields that you need. + overrides: + Dictionary of visualizer overrides to apply to the view. The key is the path to the entity where the override + should be applied. The value is a list of visualizers which should be enabled for that entity, or a single visualizer. + + Each visualizer can be configured with arbitrary overrides and mappings. + + For any entity mentioned in this map, visualizers are no longer added automatically based on the entity's components. + + Important note: the path must be a fully qualified entity path starting at the root. The override paths + do not yet support `$origin` relative paths or glob expressions. + This will be addressed in . + + config: + Configuration for the web page to embed. + + """ + + properties: dict[str, AsComponents] = {} + if config is not None: + if not isinstance(config, blueprint_archetypes.WebPageViewConfig): + config = blueprint_archetypes.WebPageViewConfig(config) + properties["WebPageViewConfig"] = config + + super().__init__( + class_identifier="WebPage", + origin=origin, + contents=contents, + name=name, + visible=visible, + properties=properties, + defaults=defaults, + overrides=overrides, + ) diff --git a/scripts/web_page_view_smoke/README.md b/scripts/web_page_view_smoke/README.md new file mode 100644 index 000000000000..250e76d2eeb0 --- /dev/null +++ b/scripts/web_page_view_smoke/README.md @@ -0,0 +1,46 @@ +# Web page view smoke scripts + +These scripts manually smoke-test the experimental native Web Page View. + +## Launch the native viewer + +```bash +scripts/web_page_view_smoke/launch_viewer.sh +``` + +The script forces the native X11 path on Linux because the current `wry` backend uses child-window embedding. + +## Send two side-by-side pages through the Python blueprint API + +```bash +scripts/web_page_view_smoke/show_two_pages.sh +``` + +Expected result: + +- left panel: `https://rerun.io` with Web Page View controls visible +- right panel: `https://example.com` with Web Page View controls hidden + +## Send a YouTube page through the Python blueprint API + +```bash +scripts/web_page_view_smoke/show_youtube.sh +``` + +Linux video playback depends on WebKitGTK/GStreamer codecs. If YouTube says the browser cannot play the video, install the system GStreamer codec plugins, then relaunch the viewer. + +## Test the DimOS websocket command path + +Start the one-shot command server in one terminal: + +```bash +python3 scripts/web_page_view_smoke/serve_dimos_ws_command.py +``` + +Launch the viewer with the websocket URL in another terminal: + +```bash +scripts/web_page_view_smoke/launch_viewer.sh --ws-url ws://127.0.0.1:3032/ws +``` + +Expected result: the DimOS websocket command creates two Web Page View panels using the same blueprint-backed Web Page View state as the Python API path. diff --git a/scripts/web_page_view_smoke/launch_viewer.sh b/scripts/web_page_view_smoke/launch_viewer.sh new file mode 100755 index 000000000000..17ba3d6a70c8 --- /dev/null +++ b/scripts/web_page_view_smoke/launch_viewer.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +LOG_PATH="${WEB_PAGE_VIEW_SMOKE_LOG:-/tmp/rerun_web_page_view_smoke.log}" + +cd "${REPO_ROOT}" + +pkill -x dimos-viewer 2>/dev/null || true +pkill -x rerun 2>/dev/null || true + +nohup env -u WAYLAND_DISPLAY \ + WINIT_UNIX_BACKEND=x11 \ + cargo run -p dimos-viewer \ + --features rerun/native_webview \ + -- \ + --new \ + "$@" \ + > "${LOG_PATH}" 2>&1 < /dev/null & + +python3 - <<'PY' +import socket +import time + +for _ in range(240): + sock = socket.socket() + try: + sock.connect(("127.0.0.1", 9876)) + print("viewer ready: rerun+http://127.0.0.1:9876/proxy") + break + except OSError: + time.sleep(0.5) + finally: + sock.close() +else: + raise SystemExit("viewer did not open gRPC port 9876") +PY + +echo "viewer log: ${LOG_PATH}" diff --git a/scripts/web_page_view_smoke/serve_dimos_ws_command.py b/scripts/web_page_view_smoke/serve_dimos_ws_command.py new file mode 100755 index 000000000000..19e8f5d82a18 --- /dev/null +++ b/scripts/web_page_view_smoke/serve_dimos_ws_command.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""One-shot websocket command server for the DimOS Web Page View smoke test.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import socket +import struct +import time + +HOST = "127.0.0.1" +PORT = 3032 +WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +COMMANDS = [ + { + "type": "open_web_page_view", + "panel_id": "webpage-rerun", + "title": "Rerun — DimOS WS", + "url": "https://rerun.io", + "show_navigation_controls": True, + }, + { + "type": "open_web_page_view", + "panel_id": "webpage-example", + "title": "Example — DimOS WS no controls", + "url": "https://example.com", + "show_navigation_controls": False, + }, +] + + +def websocket_accept_key(headers: str) -> str: + for line in headers.splitlines(): + if line.lower().startswith("sec-websocket-key:"): + key = line.split(":", 1)[1].strip() + digest = hashlib.sha1((key + WEBSOCKET_GUID).encode("ascii")).digest() + return base64.b64encode(digest).decode("ascii") + raise RuntimeError("missing Sec-WebSocket-Key") + + +def send_text_frame(conn: socket.socket, text: str) -> None: + payload = text.encode("utf-8") + header = bytearray([0x81]) + if len(payload) < 126: + header.append(len(payload)) + elif len(payload) < 65536: + header.extend([126]) + header.extend(struct.pack("!H", len(payload))) + else: + header.extend([127]) + header.extend(struct.pack("!Q", len(payload))) + conn.sendall(bytes(header) + payload) + + +def main() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((HOST, PORT)) + server.listen(1) + print(f"listening on ws://{HOST}:{PORT}/ws") + + conn, addr = server.accept() + with conn: + request = conn.recv(4096).decode("utf-8", errors="replace") + accept_key = websocket_accept_key(request) + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept_key}\r\n" + "\r\n" + ) + conn.sendall(response.encode("ascii")) + print(f"client connected from {addr[0]}:{addr[1]}") + + for command in COMMANDS: + send_text_frame(conn, json.dumps(command)) + print(f"sent command: {command['panel_id']}") + time.sleep(0.25) + + time.sleep(2.0) + + +if __name__ == "__main__": + main() diff --git a/scripts/web_page_view_smoke/show_two_pages.sh b/scripts/web_page_view_smoke/show_two_pages.sh new file mode 100755 index 000000000000..11c7b3508e5a --- /dev/null +++ b/scripts/web_page_view_smoke/show_two_pages.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${REPO_ROOT}" + +pixi run uvpy -c ' +import time +import rerun as rr +import rerun.blueprint as rrb + +rr.init("rerun_example_web_page_manual_smoke") +rr.connect_grpc("rerun+http://127.0.0.1:9876/proxy") +rr.send_blueprint( + rrb.Blueprint( + rrb.Horizontal( + rrb.WebPageView( + name="Rerun — with controls", + config=rrb.WebPageViewConfig( + url="https://rerun.io", + show_navigation_controls=True, + ), + ), + rrb.WebPageView( + name="Example — no controls", + config=rrb.WebPageViewConfig( + url="https://example.com", + show_navigation_controls=False, + ), + ), + ), + collapse_panels=True, + ) +) +time.sleep(2) +print("sent two side-by-side Web Page Views") +' diff --git a/scripts/web_page_view_smoke/show_youtube.sh b/scripts/web_page_view_smoke/show_youtube.sh new file mode 100755 index 000000000000..c9e19c18b015 --- /dev/null +++ b/scripts/web_page_view_smoke/show_youtube.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${REPO_ROOT}" + +pixi run uvpy -c ' +import time +import rerun as rr +import rerun.blueprint as rrb + +rr.init("rerun_example_web_page_youtube_smoke") +rr.connect_grpc("rerun+http://127.0.0.1:9876/proxy") +rr.send_blueprint( + rrb.Blueprint( + rrb.WebPageView( + name="YouTube", + config=rrb.WebPageViewConfig( + url="https://www.youtube.com/watch?v=aqz-KE-bpKQ", + show_navigation_controls=True, + ), + ), + collapse_panels=True, + ) +) +time.sleep(2) +print("sent YouTube smoke page") +'