From 6a3e9604c90a3b528ec8ff64c4040497a6d8b59c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 19 Aug 2026 08:03:07 +0700 Subject: [PATCH] add custom dock --- Cargo.lock | 286 ++- Cargo.toml | 2 + crates/dock/Cargo.toml | 19 + crates/dock/src/dock.rs | 509 ++++ crates/dock/src/fixtures/layout.json | 261 ++ crates/dock/src/invalid_panel.rs | 58 + crates/dock/src/lib.rs | 1305 ++++++++++ crates/dock/src/panel.rs | 386 +++ crates/dock/src/resize_handle.rs | 232 ++ crates/dock/src/stack_panel.rs | 429 ++++ crates/dock/src/state.rs | 282 +++ crates/dock/src/tab_panel.rs | 2102 +++++++++++++++++ crates/dock/src/tiles.rs | 1440 +++++++++++ crates/workspace/Cargo.toml | 1 + .../workspace/src/views/repo_detail/diff.rs | 2 +- .../workspace/src/views/repo_detail/issues.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 2 +- .../src/views/repo_detail/pull_requests.rs | 2 +- crates/workspace/src/views/repo_list.rs | 2 +- crates/workspace/src/views/sidebar/mod.rs | 2 +- crates/workspace/src/workspace.rs | 5 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 4 + 23 files changed, 7267 insertions(+), 67 deletions(-) create mode 100644 crates/dock/Cargo.toml create mode 100644 crates/dock/src/dock.rs create mode 100644 crates/dock/src/fixtures/layout.json create mode 100644 crates/dock/src/invalid_panel.rs create mode 100644 crates/dock/src/lib.rs create mode 100644 crates/dock/src/panel.rs create mode 100644 crates/dock/src/resize_handle.rs create mode 100644 crates/dock/src/stack_panel.rs create mode 100644 crates/dock/src/state.rs create mode 100644 crates/dock/src/tab_panel.rs create mode 100644 crates/dock/src/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index 37ce9d0..54b2bef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,15 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -721,15 +730,30 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" +[[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" @@ -1244,7 +1268,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "gpui_util", "indexmap", @@ -1339,6 +1363,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1690,7 +1723,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1701,7 +1734,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "proc-macro2", "quote", @@ -1790,6 +1823,19 @@ dependencies = [ "libloading", ] +[[package]] +name = "dock" +version = "1.0.0" +dependencies = [ + "anyhow", + "gpui", + "gpui-component", + "itertools 0.13.0", + "serde", + "serde_json", + "smallvec", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2037,6 +2083,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -3436,12 +3493,13 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "anyhow", "async-channel", "async-task", + "backtrace", "bindgen", "bitflags 2.13.1", "block", @@ -3466,6 +3524,7 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", + "hdrhistogram", "heapless 0.9.3", "http_client", "image", @@ -3485,6 +3544,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", + "proptest", "rand 0.9.5", "raw-window-handle", "refineable", @@ -3520,7 +3580,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "aho-corasick", "anyhow", @@ -3528,6 +3588,7 @@ dependencies = [ "chrono", "gpui", "gpui_macros", + "gpui_platform", "instant", "lsp-types", "objc2 0.6.4", @@ -3541,6 +3602,7 @@ dependencies = [ "serde_json", "smallvec", "smol", + "syntect", "tracing", "unicode-segmentation", "web-time", @@ -3550,7 +3612,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "anyhow", "chrono", @@ -3630,7 +3692,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "anyhow", "gpui", @@ -3644,7 +3706,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#da4f93696dc2b2b4d91bcc42412b9053a3d24de8" +source = "git+https://github.com/longbridge/gpui-component#9e3a29dcbdebc318632bf68203f26c33e9f0e902" dependencies = [ "proc-macro2", "quote", @@ -3654,7 +3716,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "block", @@ -3677,7 +3739,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_unix", @@ -3729,7 +3791,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_macos", @@ -3775,7 +3837,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3786,7 +3848,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "console_error_panic_hook", "gpui", @@ -3799,7 +3861,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "schemars", "serde", @@ -3809,7 +3871,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "log", @@ -3819,7 +3881,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3831,6 +3893,7 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", + "scheduler", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -3842,7 +3905,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bytemuck", @@ -3872,7 +3935,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "accesskit", "accesskit_windows", @@ -3909,9 +3972,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -3997,6 +4060,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom 8.0.0", + "num-traits", +] + [[package]] name = "heapless" version = "0.8.0" @@ -4185,7 +4262,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "async-compression", @@ -4205,7 +4282,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4460,7 +4537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -5181,7 +5258,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bindgen", @@ -5293,7 +5370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -5397,7 +5474,7 @@ dependencies = [ [[package]] name = "nostr" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "aes", "base64", @@ -5423,7 +5500,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "futures-core", @@ -5437,7 +5514,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "nostr", "opaquerr", @@ -5446,7 +5523,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "nostr", "opaquerr", @@ -5455,7 +5532,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.0" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "indexmap", "lru", @@ -5467,7 +5544,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "flatbuffers", @@ -5482,7 +5559,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "btreecap", "nostr", @@ -5493,7 +5570,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#0a10756496173b7c97d7aa611fb8663931d1eaad" +source = "git+https://github.com/rust-nostr/nostr#6969fdb5d229e15daf8839488f204014bc686058" dependencies = [ "async-utility", "async-wsocket", @@ -6229,7 +6306,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "collections", "serde", @@ -6576,6 +6653,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "psm" version = "0.1.32" @@ -6623,6 +6730,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -6660,9 +6773,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -6801,6 +6914,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -6857,7 +6979,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -6981,7 +7103,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "derive_refineable", ] @@ -7064,7 +7186,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "bytes", @@ -7410,6 +7532,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -7465,7 +7599,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "async-task", "backtrace", @@ -7474,6 +7608,7 @@ dependencies = [ "futures", "parking_lot", "rand 0.9.5", + "wasm_thread", "web-time", ] @@ -7849,6 +7984,7 @@ name = "signed" version = "1.0.0" dependencies = [ "assets", + "dock", "gpui", "gpui-component", "gpui_linux", @@ -8189,7 +8325,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "heapless 0.9.3", "log", @@ -8365,6 +8501,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.20", + "walkdir", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -8535,7 +8689,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -9341,6 +9495,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -9538,7 +9698,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "perf", "quote", @@ -9644,6 +9804,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -9976,8 +10145,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -10048,7 +10217,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "block2 0.6.2", "bytemuck", @@ -10806,6 +10975,7 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "dock", "futures", "gix", "gpui", @@ -11307,9 +11477,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", @@ -11325,7 +11495,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "anyhow", "chrono", @@ -11342,7 +11512,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" dependencies = [ "tracing", "tracing-subscriber", @@ -11353,7 +11523,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#8968bf78084f30809aa2ce1574a3be68ed02a513" +source = "git+https://github.com/zed-industries/zed#4c7244790a075e862eeb4e5ccc12d6c8f5da6f7e" [[package]] name = "zune-core" @@ -11396,9 +11566,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -11412,9 +11582,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -11425,9 +11595,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9126736..05373d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # code preview (fenced code blocks are highlighted with tree-sitter). gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] } +dock = { path = "crates/dock" } + nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-memory = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml new file mode 100644 index 0000000..423801a --- /dev/null +++ b/crates/dock/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dock" +description = "Dock (DockArea / Dock / Panel) components vendored from gpui-component." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +gpui.workspace = true +gpui-component.workspace = true + +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +itertools = "0.13.0" +smallvec = "1" + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/dock/src/dock.rs b/crates/dock/src/dock.rs new file mode 100644 index 0000000..87ca560 --- /dev/null +++ b/crates/dock/src/dock.rs @@ -0,0 +1,509 @@ +//! Dock is a fixed container that places at left, bottom, right of the Windows. + +use std::ops::Deref; +use std::sync::Arc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _, + WeakEntity, Window, div, px, +}; +use gpui_component::{Side, StyledExt}; +use serde::{Deserialize, Serialize}; + +use super::{DockArea, DockEvent, DockItem, PanelView, TabPanel}; +use crate::resize_handle::{PANEL_MIN_SIZE, resize_handle}; + +#[derive(Clone)] +struct ResizePanel; + +impl Render for ResizePanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + Empty + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum DockPlacement { + #[serde(rename = "center")] + Center, + #[serde(rename = "left")] + Left, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "right")] + Right, +} + +impl DockPlacement { + fn axis(&self) -> Axis { + match self { + Self::Left | Self::Right => Axis::Horizontal, + Self::Bottom => Axis::Vertical, + Self::Center => unreachable!(), + } + } + + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + pub fn is_bottom(&self) -> bool { + matches!(self, Self::Bottom) + } + + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// The Dock is a fixed container that places at left, bottom, right of the Windows. +/// +/// This is unlike Panel, it can't be move or add any other panel. +pub struct Dock { + pub(super) placement: DockPlacement, + dock_area: WeakEntity, + pub(crate) panel: DockItem, + /// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height. + pub(super) size: Pixels, + pub(super) open: bool, + /// Whether the Dock is collapsible, default: true + pub(super) collapsible: bool, + + // Runtime state + /// Whether the Dock is resizing + resizing: bool, +} + +impl Dock { + pub(crate) fn new( + dock_area: WeakEntity, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let panel = cx.new(|cx| { + let mut tab = TabPanel::new(None, dock_area.clone(), window, cx); + tab.closable = false; + tab + }); + + let panel = DockItem::Tabs { + size: None, + items: Vec::new(), + active_ix: 0, + view: panel.clone(), + }; + + Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); + + Self { + placement, + dock_area, + panel, + open: true, + collapsible: true, + size: px(200.0), + resizing: false, + } + } + + pub fn left( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Left, window, cx) + } + + pub fn bottom( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Bottom, window, cx) + } + + pub fn right( + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new(dock_area, DockPlacement::Right, window, cx) + } + + /// Update the Dock to be collapsible or not. + /// + /// And if the Dock is not collapsible, it will be open. + pub fn set_collapsible(&mut self, collapsible: bool, _: &mut Window, cx: &mut Context) { + self.collapsible = collapsible; + if !collapsible { + self.open = true + } + cx.notify(); + } + + pub(super) fn from_state( + dock_area: WeakEntity, + placement: DockPlacement, + size: Pixels, + panel: DockItem, + open: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx); + + if !open { + match panel.clone() { + DockItem::Tabs { view, .. } => { + view.update(cx, |panel, cx| { + panel.set_collapsed(true, window, cx); + }); + } + DockItem::Split { items, .. } => { + for item in items { + item.set_collapsed(true, window, cx); + } + } + _ => {} + } + } + + Self { + placement, + dock_area, + panel, + open, + size, + collapsible: true, + resizing: false, + } + } + + fn subscribe_panel_events( + dock_area: WeakEntity, + panel: &DockItem, + window: &mut Window, + cx: &mut Context, + ) { + match panel { + DockItem::Tabs { view, .. } => { + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Split { items, view, .. } => { + for item in items { + Self::subscribe_panel_events(dock_area.clone(), item, window, cx); + } + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Tiles { view, .. } => { + window.defer(cx, { + let view = view.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&view, window, cx); + }); + } + }); + } + DockItem::Panel { .. } => { + // Not supported + } + } + } + + pub fn set_panel(&mut self, panel: DockItem, _: &mut Window, cx: &mut Context) { + self.panel = panel; + cx.notify(); + } + + pub fn panel(&self) -> &DockItem { + &self.panel + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + self.set_open(!self.open, window, cx); + } + + /// Returns the size of the Dock, the size is means the width or height of + /// the Dock, if the placement is left or right, the size is width, + /// otherwise the size is height. + pub fn size(&self) -> Pixels { + self.size + } + + /// Set the size of the Dock. + pub fn set_size(&mut self, size: Pixels, _: &mut Window, cx: &mut Context) { + self.size = size.max(PANEL_MIN_SIZE); + cx.notify(); + } + + /// Set the open state of the Dock. + pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { + self.open = open; + let item = self.panel.clone(); + cx.defer_in(window, move |_, window, cx| { + item.set_collapsed(!open, window, cx); + }); + cx.notify(); + } + + /// Add item to the Dock. + pub fn add_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.panel + .add_panel(panel, &self.dock_area, None, window, cx); + cx.notify(); + } + + /// Remove item from the Dock. + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.panel.remove_panel(panel, window, cx); + cx.notify(); + } + + fn render_resize_handle(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let axis = self.placement.axis(); + let view = cx.entity().clone(); + + resize_handle("resize-handle", axis) + .when(self.placement == DockPlacement::Left, |this| { + this.placement(Side::Left) + }) + .on_drag(ResizePanel {}, move |info, _, _, cx| { + cx.stop_propagation(); + view.update(cx, |view, _| { + view.resizing = true; + }); + cx.new(|_| info.deref().clone()) + }) + } + + fn resize( + &mut self, + mouse_position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if !self.resizing { + return; + } + + if !self.open { + self.set_open(true, window, cx); + } + + let dock_area = self + .dock_area + .upgrade() + .expect("DockArea is missing") + .read(cx); + let area_bounds = dock_area.bounds; + let mut left_dock_size = px(0.0); + let mut right_dock_size = px(0.0); + + // Get the size of the left dock if it's open and not the current dock + if let Some(left_dock) = &dock_area.left_dock + && left_dock.entity_id() != cx.entity().entity_id() + { + let left_dock_read = left_dock.read(cx); + if left_dock_read.is_open() { + left_dock_size = left_dock_read.size; + } + } + + // Get the size of the right dock if it's open and not the current dock + if let Some(right_dock) = &dock_area.right_dock + && right_dock.entity_id() != cx.entity().entity_id() + { + let right_dock_read = right_dock.read(cx); + if right_dock_read.is_open() { + right_dock_size = right_dock_read.size; + } + } + + let size = match self.placement { + DockPlacement::Left => mouse_position.x - area_bounds.left(), + DockPlacement::Right => area_bounds.right() - mouse_position.x, + DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y, + DockPlacement::Center => unreachable!(), + }; + match self.placement { + DockPlacement::Left => { + let max_size = + (area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Right => { + let max_size = + (area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Bottom => { + let max_size = (area_bounds.size.height - PANEL_MIN_SIZE).max(PANEL_MIN_SIZE); + self.size = size.clamp(PANEL_MIN_SIZE, max_size); + } + DockPlacement::Center => unreachable!(), + } + + cx.notify(); + } + + fn done_resizing(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.resizing { + return; + } + self.resizing = false; + + // Dragging the dock's resize handle finished, bubble a layout change + // so subscribers can persist the new dock size. + _ = self.dock_area.update(cx, |_, cx| { + cx.emit(DockEvent::LayoutChanged); + }); + } +} + +impl Render for Dock { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { + if !self.open && !self.placement.is_bottom() { + return div(); + } + + let cache_style = StyleRefinement::default().absolute().size_full(); + + div() + .relative() + .overflow_hidden() + .map(|this| match self.placement { + DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size), + DockPlacement::Bottom => this.w_full().h(self.size), + DockPlacement::Center => unreachable!(), + }) + // Bottom Dock should keep the title bar, then user can click the Toggle button + .when(!self.open && self.placement.is_bottom(), |this| { + this.h(px(29.)) + }) + .map(|this| match &self.panel { + DockItem::Split { view, .. } => this.child(view.clone()), + DockItem::Tabs { view, .. } => this.child(view.clone()), + DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), + // Not support to render Tiles and Tile into Dock + DockItem::Tiles { .. } => this, + }) + .child(self.render_resize_handle(window, cx)) + .child(DockElement { + view: cx.entity().clone(), + }) + } +} + +struct DockElement { + view: Entity, +} + +impl IntoElement for DockElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for DockElement { + type PrepaintState = (); + type RequestLayoutState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut gpui::Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _window: &mut gpui::Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut gpui::Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let view = self.view.clone(); + let resizing = view.read(cx).resizing; + move |e: &MouseMoveEvent, phase, window, cx| { + if !resizing { + return; + } + if !phase.bubble() { + return; + } + + view.update(cx, |view, cx| view.resize(e.position, window, cx)) + } + }); + + // When any mouse up, stop dragging + window.on_mouse_event({ + let view = self.view.clone(); + move |_: &MouseUpEvent, phase, window, cx| { + if phase.bubble() { + view.update(cx, |view, cx| view.done_resizing(window, cx)); + } + } + }) + } +} diff --git a/crates/dock/src/fixtures/layout.json b/crates/dock/src/fixtures/layout.json new file mode 100644 index 0000000..13b7a76 --- /dev/null +++ b/crates/dock/src/fixtures/layout.json @@ -0,0 +1,261 @@ +{ + "center": { + "panel_name": "StackPanel", + "children": [ + { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ButtonStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "InputStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TextStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "SelectStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "DialogStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "SwitchStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ProgressStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "DataTableStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ImageStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "IconStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TooltipStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ProgressStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "CalendarStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ResizableStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ScrollbarStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "PopupStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + } + ], + "info": { + "stack": { + "sizes": [704.0, 263.0], + "axis": 1 + } + } + }, + "left_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ListStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "left", + "size": 350.0, + "open": true, + "resizeable": true + }, + "right_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "ImageStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "right", + "size": 320.0, + "open": true, + "resizeable": true + }, + "bottom_dock": { + "panel": { + "panel_name": "TabPanel", + "children": [ + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "TextStory" + } + } + }, + { + "panel_name": "StoryContainer", + "children": [], + "info": { + "panel": { + "story_klass": "IconStory" + } + } + } + ], + "info": { + "tabs": { + "active_index": 0 + } + } + }, + "placement": "bottom", + "size": 200.0, + "open": true, + "resizeable": true + } +} diff --git a/crates/dock/src/invalid_panel.rs b/crates/dock/src/invalid_panel.rs new file mode 100644 index 0000000..ff5c8c7 --- /dev/null +++ b/crates/dock/src/invalid_panel.rs @@ -0,0 +1,58 @@ +use gpui::{ + App, EventEmitter, FocusHandle, Focusable, ParentElement as _, Render, SharedString, + Styled as _, Window, +}; +use gpui_component::ActiveTheme as _; + +use super::{Panel, PanelEvent, PanelState}; + +pub(crate) struct InvalidPanel { + name: SharedString, + focus_handle: FocusHandle, + old_state: PanelState, +} + +impl InvalidPanel { + pub(crate) fn new(name: &str, state: PanelState, _: &mut Window, cx: &mut App) -> Self { + Self { + focus_handle: cx.focus_handle(), + name: SharedString::from(name.to_owned()), + old_state: state, + } + } +} +impl Panel for InvalidPanel { + fn panel_name(&self) -> &'static str { + "InvalidPanel" + } + + fn dump(&self, _cx: &App) -> super::PanelState { + self.old_state.clone() + } +} +impl EventEmitter for InvalidPanel {} +impl Focusable for InvalidPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl Render for InvalidPanel { + fn render( + &mut self, + _: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::div() + .size_full() + .my_6() + .flex() + .flex_col() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(format!( + "The `{}` panel type is not registered in PanelRegistry.", + self.name.clone() + )) + } +} diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs new file mode 100644 index 0000000..5f2227c --- /dev/null +++ b/crates/dock/src/lib.rs @@ -0,0 +1,1305 @@ +//! Dock (DockArea / Dock / Panel) components. +//! +//! Vendored from [`gpui-component`]'s `dock` module (v0.5.2, +//! rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902) so it can be customized +//! in-tree. Everything else (Button, TabBar, menus, icons, ...) is imported +//! from `gpui-component` directly. + +mod dock; +mod invalid_panel; +mod panel; +mod resize_handle; +mod stack_panel; +mod state; +mod tab_panel; +mod tiles; + +use std::sync::Arc; + +use anyhow::Result; +pub use dock::*; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, EntityId, + EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Render, + SharedString, Styled, Subscription, WeakEntity, Window, actions, div, +}; +use gpui_component::{ElementExt, Placement}; +pub use panel::*; +pub use stack_panel::*; +pub use state::*; +pub use tab_panel::*; +pub use tiles::*; + +/// Initialize the dock, registering the [`PanelRegistry`] global. +/// +/// Call this from your app entry point, before building any [`DockArea`]. +/// It is idempotent, so it is safe to call alongside `gpui_component::init`. +pub fn init(cx: &mut App) { + PanelRegistry::init(cx); +} + +// Note: the action group name must not collide with gpui-component's own +// `dock::` actions, which are linked into the same binary while the app still +// depends on gpui-component (action names are registered globally per App). +actions!(signed_dock, [ToggleZoom, ClosePanel]); + +/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`. +/// +/// The upstream dock used `t!("Dock.*")` keys; we keep the same keys but +/// resolve them to the English strings so the crate has no i18n dependency. +pub(crate) fn t(key: &'static str) -> &'static str { + match key { + "Dock.Unnamed" => "Unnamed", + "Dock.Close" => "Close", + "Dock.Zoom In" => "Zoom In", + "Dock.Zoom Out" => "Zoom Out", + "Dock.Collapse" => "Collapse", + "Dock.Expand" => "Expand", + _ => key, + } +} + +pub enum DockEvent { + /// The layout of the dock has changed, subscribers this to save the layout. + /// + /// This event is emitted when every time the layout of the dock has changed, + /// So it emits may be too frequently, you may want to debounce the event. + LayoutChanged, + + /// A host-owned drag item ([`AnyDrag`]) was dropped inside the dock. + DragDrop { item: AnyDrag, target: DropTarget }, +} + +/// Where a host-owned drag landed, and how much the container can say about it. +#[derive(Clone, Debug)] +pub enum DropTarget { + /// Dropped on a [`Tiles`] canvas, where the landing position is just the + /// cursor position and the host can read it directly. + Canvas, + + /// Dropped on a [`TabPanel`] in a split layout. A split layout has no free + /// coordinates, so the container reports the panel and the edge it resolved + /// from the cursor instead. + /// + /// `placement` is `None` for the centre zone, meaning merge into the tab + /// group rather than split. + Panel { + tab_panel: Entity, + placement: Option, + }, +} + +/// The main area of the dock. +pub struct DockArea { + id: SharedString, + /// The version is used to special the default layout, this is like the `panel_version` in [`Panel`](Panel). + version: Option, + pub(crate) bounds: Bounds, + + /// The center view of the dock_area. + center: DockItem, + /// The left dock of the dock_area. + left_dock: Option>, + /// The bottom dock of the dock_area. + bottom_dock: Option>, + /// The right dock of the dock_area. + right_dock: Option>, + + /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, + toggle_button_panels: Edges>, + + /// Whether to show the toggle button. + toggle_button_visible: bool, + /// The top zoom view of the dock_area, if any. + zoom_view: Option, + + /// Lock panels layout, but allow to resize. + locked: bool, + + /// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default). + pub(crate) panel_style: PanelStyle, + + _subscriptions: Vec, +} + +/// DockItem is a tree structure that represents the layout of the dock. +#[derive(Clone)] +pub enum DockItem { + /// Split layout + Split { + axis: Axis, + /// Self size, only used for build split panels + size: Option, + items: Vec, + /// Items sizes + sizes: Vec>, + view: Entity, + }, + /// Tab layout + Tabs { + /// Self size, only used for build split panels + size: Option, + items: Vec>, + active_ix: usize, + view: Entity, + }, + /// Panel layout + Panel { + /// Self size, only used for build split panels + size: Option, + view: Arc, + }, + /// Tiles layout + Tiles { + /// Self size, only used for build split panels + size: Option, + items: Vec, + view: Entity, + }, +} + +impl std::fmt::Debug for DockItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DockItem::Split { + axis, items, sizes, .. + } => f + .debug_struct("Split") + .field("axis", axis) + .field("items", &items.len()) + .field("sizes", sizes) + .finish(), + DockItem::Tabs { + items, active_ix, .. + } => f + .debug_struct("Tabs") + .field("items", &items.len()) + .field("active_ix", active_ix) + .finish(), + DockItem::Panel { .. } => f.debug_struct("Panel").finish(), + DockItem::Tiles { .. } => f.debug_struct("Tiles").finish(), + } + } +} + +impl DockItem { + /// Get the size of the DockItem. + fn get_size(&self) -> Option { + match self { + Self::Split { size, .. } => *size, + Self::Tabs { size, .. } => *size, + Self::Panel { size, .. } => *size, + Self::Tiles { size, .. } => *size, + } + } + + /// Set size for the DockItem. + pub fn size(mut self, new_size: impl Into) -> Self { + let new_size: Option = Some(new_size.into()); + match self { + Self::Split { ref mut size, .. } => *size = new_size, + Self::Tabs { ref mut size, .. } => *size = new_size, + Self::Tiles { ref mut size, .. } => *size = new_size, + Self::Panel { ref mut size, .. } => *size = new_size, + } + self + } + + /// Set active index for the DockItem, only valid for [`DockItem::Tabs`]. + pub fn active_index(mut self, new_active_ix: usize, cx: &mut App) -> Self { + debug_assert!( + matches!(self, Self::Tabs { .. }), + "active_ix can only be set for DockItem::Tabs" + ); + + if let Self::Tabs { + ref mut active_ix, + ref mut view, + .. + } = self + { + *active_ix = new_active_ix; + view.update(cx, |tab_panel, _| { + tab_panel.active_ix = new_active_ix; + }); + } + self + } + + /// Create DockItem::Split with given split layout. + pub fn split( + axis: Axis, + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let sizes = items.iter().map(|item| item.get_size()).collect(); + Self::split_with_sizes(axis, items, sizes, dock_area, window, cx) + } + + /// Create DockItem with vertical split layout. + pub fn v_split( + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::split(Axis::Vertical, items, dock_area, window, cx) + } + + /// Create DockItem with horizontal split layout. + pub fn h_split( + items: Vec, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::split(Axis::Horizontal, items, dock_area, window, cx) + } + + /// Create DockItem with split layout, each item of panel have specified size. + /// + /// Please note that the `items` and `sizes` must have the same length. + /// Set `None` in `sizes` to make the index of panel have auto size. + pub fn split_with_sizes( + axis: Axis, + items: Vec, + sizes: Vec>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let stack_panel = cx.new(|cx| { + let mut stack_panel = StackPanel::new(axis, window, cx); + for (i, item) in items.iter().enumerate() { + let view = item.view(); + let size = sizes.get(i).copied().flatten(); + stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx) + } + + stack_panel + }); + + window.defer(cx, { + let stack_panel = stack_panel.clone(); + let dock_area = dock_area.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&stack_panel, window, cx); + }); + } + }); + + Self::Split { + axis, + size: None, + items, + sizes, + view: stack_panel, + } + } + + /// Create DockItem with panel layout + pub fn panel(panel: Arc) -> Self { + Self::Panel { + size: None, + view: panel, + } + } + + /// Create DockItem with tiles layout + /// + /// This items and metas should have the same length. + pub fn tiles( + items: Vec, + metas: Vec + Copy>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + assert!(items.len() == metas.len()); + + let tile_panel = cx.new(|cx| { + let mut tiles = Tiles::new(window, cx); + for (ix, item) in items.clone().into_iter().enumerate() { + match item { + DockItem::Tabs { view, .. } => { + let meta: TileMeta = metas[ix].into(); + let tile_item = + TileItem::new(Arc::new(view), meta.bounds).z_index(meta.z_index); + tiles.add_item(tile_item, dock_area, window, cx); + } + DockItem::Panel { view, .. } => { + let meta: TileMeta = metas[ix].into(); + let tile_item = + TileItem::new(view.clone(), meta.bounds).z_index(meta.z_index); + tiles.add_item(tile_item, dock_area, window, cx); + } + _ => { + // Ignore non-tabs items + } + } + } + tiles + }); + + window.defer(cx, { + let tile_panel = tile_panel.clone(); + let dock_area = dock_area.clone(); + move |window, cx| { + _ = dock_area.update(cx, |this, cx| { + this.subscribe_panel(&tile_panel, window, cx); + this.subscribe_tiles_item_drop(&tile_panel, window, cx); + }); + } + }); + + Self::Tiles { + size: None, + items: tile_panel.read(cx).panels.clone(), + view: tile_panel, + } + } + + /// Create DockItem with tabs layout, items are displayed as tabs. + /// + /// The `active_ix` is the index of the active tab, if `None` the first tab is active. + pub fn tabs( + items: Vec>, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut new_items: Vec> = vec![]; + for item in items.into_iter() { + new_items.push(item) + } + Self::new_tabs(new_items, None, dock_area, window, cx) + } + + pub fn tab( + item: Entity

, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, window, cx) + } + + fn new_tabs( + items: Vec>, + active_ix: Option, + dock_area: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Self { + let active_ix = active_ix.unwrap_or(0); + let tab_panel = cx.new(|cx| { + let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); + for item in items.iter() { + tab_panel.add_panel(item.clone(), window, cx) + } + tab_panel.active_ix = active_ix; + tab_panel + }); + + Self::Tabs { + size: None, + items, + active_ix, + view: tab_panel, + } + } + + /// Returns the views of the dock item. + pub fn view(&self) -> Arc { + match self { + Self::Split { view, .. } => Arc::new(view.clone()), + Self::Tabs { view, .. } => Arc::new(view.clone()), + Self::Tiles { view, .. } => Arc::new(view.clone()), + Self::Panel { view, .. } => view.clone(), + } + } + + /// Whether this dock item currently holds no visible panel. + /// + /// Walks the live panel entities, not `items`: [`Self::add_panel`] only + /// pushes into them, nothing ever removes, and splitting does not touch + /// them at all. + /// + /// A container is empty when every child is, so a fresh one is empty. A + /// leaf counts as empty while it is hidden, matching the render path, which + /// skips panels whose [`Panel::visible`] is `false`. + pub fn is_empty(&self, cx: &App) -> bool { + fn is_empty(panel: &Arc, cx: &App) -> bool { + let view = panel.view(); + + if let Ok(stack) = view.clone().downcast::() { + return stack + .read(cx) + .panels + .iter() + .all(|panel| is_empty(panel, cx)); + } + if let Ok(tabs) = view.clone().downcast::() { + return tabs.read(cx).panels.iter().all(|panel| is_empty(panel, cx)); + } + if let Ok(tiles) = view.downcast::() { + return tiles + .read(cx) + .panels() + .iter() + .all(|item| is_empty(&item.panel, cx)); + } + + !panel.visible(cx) + } + + is_empty(&self.view(), cx) + } + + /// Find existing panel in the dock item. + pub fn find_panel(&self, panel: Arc) -> Option> { + match self { + Self::Split { items, .. } => { + items.iter().find_map(|item| item.find_panel(panel.clone())) + } + Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(), + Self::Panel { view, .. } => Some(view.clone()), + Self::Tiles { items, .. } => items.iter().find_map(|item| { + // `==` on `Arc` moves the captured `panel` + // inside the closure; `eq` borrows both sides. + if item.panel.eq(&panel) { + Some(item.panel.clone()) + } else { + None + } + }), + } + } + + /// Add a panel to the dock item. + pub fn add_panel( + &mut self, + panel: Arc, + dock_area: &WeakEntity, + bounds: Option>, + window: &mut Window, + cx: &mut App, + ) { + match self { + Self::Tabs { view, items, .. } => { + items.push(panel.clone()); + view.update(cx, |tab_panel, cx| { + tab_panel.add_panel(panel, window, cx); + }); + } + Self::Split { view, items, .. } => { + // Iter items to add panel to the first tabs + for item in items.iter_mut() { + if let DockItem::Tabs { view, .. } = item { + view.update(cx, |tab_panel, cx| { + tab_panel.add_panel(panel.clone(), window, cx); + }); + return; + } + } + + // Unable to find tabs, create new tabs + let new_item = Self::tabs(vec![panel.clone()], dock_area, window, cx); + items.push(new_item.clone()); + view.update(cx, |stack_panel, cx| { + stack_panel.add_panel(new_item.view(), None, dock_area.clone(), window, cx); + }); + } + Self::Tiles { view, items, .. } => { + let tile_item = TileItem::new( + Arc::new(cx.new(|cx| { + let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx); + tab_panel.add_panel(panel.clone(), window, cx); + tab_panel + })), + bounds.unwrap_or_else(|| TileMeta::default().bounds), + ); + + items.push(tile_item.clone()); + view.update(cx, |tiles, cx| { + tiles.add_item(tile_item, dock_area, window, cx); + }); + } + Self::Panel { .. } => {} + } + } + + /// Remove a panel from the dock item. + pub fn remove_panel(&self, panel: Arc, window: &mut Window, cx: &mut App) { + match self { + DockItem::Tabs { view, .. } => { + view.update(cx, |tab_panel, cx| { + tab_panel.remove_panel(panel, window, cx); + }); + } + DockItem::Split { items, view, .. } => { + // For each child item, set collapsed state + for item in items { + item.remove_panel(panel.clone(), window, cx); + } + view.update(cx, |split, cx| { + split.remove_panel(panel, window, cx); + }); + } + DockItem::Tiles { view, .. } => { + view.update(cx, |tiles, cx| { + tiles.remove(panel, window, cx); + }); + } + DockItem::Panel { .. } => {} + } + } + + pub fn set_collapsed(&self, collapsed: bool, window: &mut Window, cx: &mut App) { + match self { + DockItem::Tabs { view, .. } => { + view.update(cx, |tab_panel, cx| { + tab_panel.set_collapsed(collapsed, window, cx); + }); + } + DockItem::Split { items, .. } => { + // For each child item, set collapsed state + for item in items { + item.set_collapsed(collapsed, window, cx); + } + } + DockItem::Tiles { .. } => {} + DockItem::Panel { view, .. } => view.set_active(!collapsed, window, cx), + } + } + + /// Recursively traverses to find the left-most and top-most TabPanel. + pub(crate) fn left_top_tab_panel(&self, cx: &App) -> Option> { + match self { + DockItem::Tabs { view, .. } => Some(view.clone()), + DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx), + DockItem::Tiles { .. } => None, + DockItem::Panel { .. } => None, + } + } + + /// Recursively traverses to find the right-most and top-most TabPanel. + pub(crate) fn right_top_tab_panel(&self, cx: &App) -> Option> { + match self { + DockItem::Tabs { view, .. } => Some(view.clone()), + DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx), + DockItem::Tiles { .. } => None, + DockItem::Panel { .. } => None, + } + } +} + +impl DockArea { + pub fn new( + id: impl Into, + version: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let stack_panel = cx.new(|cx| StackPanel::new(Axis::Horizontal, window, cx)); + + let dock_item = DockItem::Split { + axis: Axis::Horizontal, + size: None, + items: vec![], + sizes: vec![], + view: stack_panel.clone(), + }; + + let mut this = Self { + id: id.into(), + version, + bounds: Bounds::default(), + center: dock_item, + left_dock: None, + right_dock: None, + bottom_dock: None, + zoom_view: None, + toggle_button_panels: Edges::default(), + toggle_button_visible: true, + locked: false, + panel_style: PanelStyle::default(), + _subscriptions: vec![], + }; + + this.subscribe_panel(&stack_panel, window, cx); + + this + } + + /// Return the bounds of the dock area. + pub fn bounds(&self) -> Bounds { + self.bounds + } + + /// Subscribe to the tiles item drag item drop event + fn subscribe_tiles_item_drop( + &mut self, + tile_panel: &Entity, + _: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.subscribe(tile_panel, move |_, _, evt: &DragDrop, cx| { + cx.emit(DockEvent::DragDrop { + item: evt.0.clone(), + target: DropTarget::Canvas, + }); + })); + } + + /// Set the panel style of the dock area. + pub fn panel_style(mut self, style: PanelStyle) -> Self { + self.panel_style = style; + self + } + + /// Set version of the dock area. + pub fn set_version(&mut self, version: usize, _: &mut Window, cx: &mut Context) { + self.version = Some(version); + cx.notify(); + } + + /// Return the center dock item. + pub fn center(&self) -> &DockItem { + &self.center + } + + /// Whether the center area currently holds no visible panel. + /// + /// See [`DockItem::is_empty`]. Ask a dock the same question with + /// [`Dock::panel`]. + pub fn is_center_empty(&self, cx: &App) -> bool { + self.center.is_empty(cx) + } + + /// Return the left dock item. + pub fn left_dock(&self) -> Option<&Entity> { + self.left_dock.as_ref() + } + + /// Return the bottom dock item. + pub fn bottom_dock(&self) -> Option<&Entity> { + self.bottom_dock.as_ref() + } + + /// Return the right dock item. + pub fn right_dock(&self) -> Option<&Entity> { + self.right_dock.as_ref() + } + + /// Remove the left dock. + pub fn remove_left_dock(&mut self, _: &mut Window, _: &mut Context) { + self.left_dock = None; + } + + /// Remove the bottom dock. + pub fn remove_bottom_dock(&mut self, _: &mut Window, _: &mut Context) { + self.bottom_dock = None; + } + + /// Remove the right dock. + pub fn remove_right_dock(&mut self, _: &mut Window, _: &mut Context) { + self.right_dock = None; + } + + /// The the DockItem as the center of the dock area. + /// + /// This is used to render at the Center of the DockArea. + pub fn set_center(&mut self, center: DockItem, window: &mut Window, cx: &mut Context) { + self.subscribe_item(¢er, window, cx); + self.center = center; + self.update_toggle_button_tab_panels(window, cx); + cx.notify(); + } + + pub fn set_left_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.left_dock = Some(cx.new(|cx| { + let mut dock = Dock::left(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + pub fn set_bottom_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.bottom_dock = Some(cx.new(|cx| { + let mut dock = Dock::bottom(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + pub fn set_right_dock( + &mut self, + panel: DockItem, + size: Option, + open: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.subscribe_item(&panel, window, cx); + let weak_self = cx.entity().downgrade(); + self.right_dock = Some(cx.new(|cx| { + let mut dock = Dock::right(weak_self.clone(), window, cx); + if let Some(size) = size { + dock.set_size(size, window, cx); + } + dock.set_panel(panel, window, cx); + dock.set_open(open, window, cx); + dock + })); + self.update_toggle_button_tab_panels(window, cx); + } + + /// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels. + pub fn set_locked(&mut self, locked: bool, _window: &mut Window, _cx: &mut App) { + self.locked = locked; + } + + /// Determine if the dock area is locked. + #[inline] + pub fn is_locked(&self) -> bool { + self.locked + } + + /// Determine if the dock area has a dock at the given placement. + pub fn has_dock(&self, placement: DockPlacement) -> bool { + match placement { + DockPlacement::Left => self.left_dock.is_some(), + DockPlacement::Bottom => self.bottom_dock.is_some(), + DockPlacement::Right => self.right_dock.is_some(), + DockPlacement::Center => false, + } + } + + /// Determine if the dock at the given placement is open. + pub fn is_dock_open(&self, placement: DockPlacement, cx: &App) -> bool { + match placement { + DockPlacement::Left => self + .left_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Bottom => self + .bottom_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Right => self + .right_dock + .as_ref() + .map(|dock| dock.read(cx).is_open()) + .unwrap_or(false), + DockPlacement::Center => false, + } + } + + /// Set the dock at the given placement to be open or closed. + /// + /// Only the left, bottom, right dock can be toggled. + pub fn set_dock_collapsible( + &mut self, + collapsible_edges: Edges, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(left_dock) = self.left_dock.as_ref() { + left_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.left, window, cx); + }); + } + + if let Some(bottom_dock) = self.bottom_dock.as_ref() { + bottom_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.bottom, window, cx); + }); + } + + if let Some(right_dock) = self.right_dock.as_ref() { + right_dock.update(cx, |dock, cx| { + dock.set_collapsible(collapsible_edges.right, window, cx); + }); + } + } + + /// Determine if the dock at the given placement is collapsible. + pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &App) -> bool { + match placement { + DockPlacement::Left => self + .left_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Bottom => self + .bottom_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Right => self + .right_dock + .as_ref() + .map(|dock| dock.read(cx).collapsible) + .unwrap_or(false), + DockPlacement::Center => false, + } + } + + /// Toggle the dock at the given placement. + pub fn toggle_dock( + &self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + let dock = match placement { + DockPlacement::Left => &self.left_dock, + DockPlacement::Bottom => &self.bottom_dock, + DockPlacement::Right => &self.right_dock, + DockPlacement::Center => return, + }; + + if let Some(dock) = dock { + dock.update(cx, |view, cx| { + view.toggle_open(window, cx); + }) + } + } + + /// Set the visibility of the toggle button. + pub fn set_toggle_button_visible(&mut self, visible: bool, _: &mut Context) { + self.toggle_button_visible = visible; + } + + /// Add a panel item to the dock area at the given placement. + pub fn add_panel( + &mut self, + panel: Arc, + placement: DockPlacement, + bounds: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let weak_self = cx.entity().downgrade(); + match placement { + DockPlacement::Left => { + if let Some(dock) = self.left_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_left_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Bottom => { + if let Some(dock) = self.bottom_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_bottom_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Right => { + if let Some(dock) = self.right_dock.as_ref() { + dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) + } else { + self.set_right_dock( + DockItem::tabs(vec![panel], &weak_self, window, cx), + None, + true, + window, + cx, + ); + } + } + DockPlacement::Center => { + self.center + .add_panel(panel, &cx.entity().downgrade(), bounds, window, cx); + } + } + } + + /// Remove panel from the DockArea at the given placement. + pub fn remove_panel( + &mut self, + panel: Arc, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) { + match placement { + DockPlacement::Left => { + if let Some(dock) = self.left_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Right => { + if let Some(dock) = self.right_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Bottom => { + if let Some(dock) = self.bottom_dock.as_mut() { + dock.update(cx, |dock, cx| { + dock.remove_panel(panel, window, cx); + }); + } + } + DockPlacement::Center => { + self.center.remove_panel(panel, window, cx); + } + } + cx.notify(); + } + + /// Remove a panel from all docks. + pub fn remove_panel_from_all_docks( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.remove_panel(panel.clone(), DockPlacement::Center, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Left, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Right, window, cx); + self.remove_panel(panel.clone(), DockPlacement::Bottom, window, cx); + } + + /// Load the state of the DockArea from the DockAreaState. + /// + /// See also [DockeArea::dump]. + pub fn load( + &mut self, + state: DockAreaState, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + self.version = state.version; + let weak_self = cx.entity().downgrade(); + + if let Some(left_dock_state) = state.left_dock { + self.left_dock = Some(left_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + if let Some(right_dock_state) = state.right_dock { + self.right_dock = Some(right_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + if let Some(bottom_dock_state) = state.bottom_dock { + self.bottom_dock = Some(bottom_dock_state.to_dock(weak_self.clone(), window, cx)); + } + + self.center = state.center.to_item(weak_self, window, cx); + self.update_toggle_button_tab_panels(window, cx); + Ok(()) + } + + /// Dump the dock panels layout to PanelState. + /// + /// See also [DockArea::load]. + pub fn dump(&self, cx: &App) -> DockAreaState { + let root = self.center.view(); + let center = root.dump(cx); + + let left_dock = self + .left_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + let right_dock = self + .right_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + let bottom_dock = self + .bottom_dock + .as_ref() + .map(|dock| DockState::new(dock.clone(), cx)); + + DockAreaState { + version: self.version, + center, + left_dock, + right_dock, + bottom_dock, + } + } + + /// Subscribe event on the panels + #[allow(clippy::only_used_in_recursion)] + fn subscribe_item(&mut self, item: &DockItem, window: &mut Window, cx: &mut Context) { + match item { + DockItem::Split { items, view, .. } => { + for item in items { + self.subscribe_item(item, window, cx); + } + + self._subscriptions.push(cx.subscribe_in( + view, + window, + move |_, _, event, window, cx| { + if let PanelEvent::LayoutChanged = event { + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.update_toggle_button_tab_panels(window, cx) + }); + }) + .detach(); + cx.emit(DockEvent::LayoutChanged); + } + }, + )); + } + DockItem::Tabs { .. } => { + // We subscribe to the tab panel event in StackPanel's insert_panel + } + DockItem::Tiles { .. } => { + // We subscribe to the tab panel event in Tiles's [`add_item`](Tiles::add_item) + } + DockItem::Panel { .. } => { + // Not supported + } + } + } + + /// Subscribe zoom event on the panel + pub(crate) fn subscribe_panel( + &mut self, + view: &Entity

, + window: &mut Window, + cx: &mut Context, + ) { + let subscription = + cx.subscribe_in( + view, + window, + move |_, panel, event, window, cx| match event { + PanelEvent::ZoomIn => { + let panel = panel.clone(); + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.set_zoomed_in(panel, window, cx); + cx.notify(); + }); + }) + .detach(); + } + PanelEvent::ZoomOut => cx + .spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.set_zoomed_out(window, cx); + }); + }) + .detach(), + PanelEvent::LayoutChanged => { + cx.spawn_in(window, async move |view, window| { + _ = view.update_in(window, |view, window, cx| { + view.update_toggle_button_tab_panels(window, cx) + }); + }) + .detach(); + cx.emit(DockEvent::LayoutChanged); + } + }, + ); + + self._subscriptions.push(subscription); + } + + /// Returns the ID of the dock area. + pub fn id(&self) -> SharedString { + self.id.clone() + } + + pub fn set_zoomed_in( + &mut self, + panel: Entity

, + _: &mut Window, + cx: &mut Context, + ) { + self.zoom_view = Some(panel.into()); + cx.notify(); + } + + pub fn set_zoomed_out(&mut self, _: &mut Window, cx: &mut Context) { + self.zoom_view = None; + cx.notify(); + } + + fn render_items(&self, _window: &mut Window, _cx: &mut Context) -> AnyElement { + match &self.center { + DockItem::Split { view, .. } => view.clone().into_any_element(), + DockItem::Tabs { view, .. } => view.clone().into_any_element(), + DockItem::Tiles { view, .. } => view.clone().into_any_element(), + DockItem::Panel { view, .. } => view.clone().view().into_any_element(), + } + } + + pub fn update_toggle_button_tab_panels(&mut self, _: &mut Window, cx: &mut Context) { + // Left toggle button + self.toggle_button_panels.left = self + .center + .left_top_tab_panel(cx) + .map(|view| view.entity_id()); + + // Right toggle button + self.toggle_button_panels.right = self + .center + .right_top_tab_panel(cx) + .map(|view| view.entity_id()); + + // Bottom toggle button + self.toggle_button_panels.bottom = self + .bottom_dock + .as_ref() + .and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx)) + .map(|view| view.entity_id()); + } +} +impl EventEmitter for DockArea {} +impl Render for DockArea { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity().clone(); + + div() + .id("dock-area") + .relative() + .size_full() + .overflow_hidden() + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) + .map(|this| { + if let Some(zoom_view) = self.zoom_view.clone() { + this.child(zoom_view) + } else { + match &self.center { + DockItem::Tiles { view, .. } => { + // render tiles + this.child(view.clone()) + } + _ => { + // render dock + this.child( + div() + .flex() + .flex_row() + .h_full() + // Left dock + .when_some(self.left_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }) + // Center + .child( + div() + .flex() + .flex_1() + .flex_col() + .overflow_hidden() + // Top center + .child( + div() + .flex_1() + .overflow_hidden() + .child(self.render_items(window, cx)), + ) + // Bottom Dock + .when_some(self.bottom_dock.clone(), |this, dock| { + this.child(dock) + }), + ) + // Right Dock + .when_some(self.right_dock.clone(), |this, dock| { + this.child(div().flex().flex_none().child(dock)) + }), + ) + } + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + + use super::*; + + #[gpui::test] + fn split_with_sizes_adds_each_child_once(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(gpui_component::Theme::default()); + cx.open_window(Default::default(), |window, cx| { + let dock_area = cx.new(|cx| DockArea::new("test-dock", None, window, cx)); + let weak_dock_area = dock_area.downgrade(); + let children = vec![ + DockItem::tabs(Vec::new(), &weak_dock_area, window, cx), + DockItem::tabs(Vec::new(), &weak_dock_area, window, cx), + ]; + + let split = DockItem::split_with_sizes( + Axis::Horizontal, + children, + vec![None, None], + &weak_dock_area, + window, + cx, + ); + + let DockItem::Split { view, .. } = split else { + unreachable!("split_with_sizes must return DockItem::Split"); + }; + assert_eq!(view.read(cx).panels_len(), 2); + + cx.new(|cx| gpui_component::Root::new(dock_area, window, cx)) + }) + .unwrap(); + }); + } +} diff --git a/crates/dock/src/panel.rs b/crates/dock/src/panel.rs new file mode 100644 index 0000000..afd0cc9 --- /dev/null +++ b/crates/dock/src/panel.rs @@ -0,0 +1,386 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use gpui::{ + AnyElement, AnyView, App, AppContext as _, Context, Entity, EntityId, EventEmitter, + FocusHandle, Focusable, Global, Hsla, IntoElement, Render, SharedString, WeakEntity, Window, +}; +use gpui_component::button::Button; +use gpui_component::menu::PopupMenu; + +use super::{DockArea, PanelInfo, PanelState, TabPanel}; +use crate::invalid_panel::InvalidPanel; +use crate::t; + +pub enum PanelEvent { + ZoomIn, + ZoomOut, + LayoutChanged, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PanelStyle { + /// Display the TabBar when there are multiple tabs, otherwise display the simple title. + #[default] + Auto, + /// Always display the tab bar. + TabBar, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TitleStyle { + pub background: Hsla, + pub foreground: Hsla, +} + +#[derive(Clone, Copy, Default)] +pub enum PanelControl { + Both, + #[default] + Menu, + Toolbar, +} + +impl PanelControl { + #[inline] + pub fn toolbar_visible(&self) -> bool { + matches!(self, PanelControl::Both | PanelControl::Toolbar) + } + + #[inline] + pub fn menu_visible(&self) -> bool { + matches!(self, PanelControl::Both | PanelControl::Menu) + } +} + +/// The Panel trait used to define the panel. +#[allow(unused_variables)] +pub trait Panel: EventEmitter + Render + Focusable { + /// The name of the panel used to serialize, deserialize and identify the panel. + /// + /// This is used to identify the panel when deserializing the panel. + /// Once you have defined a panel name, this must not be changed. + fn panel_name(&self) -> &'static str; + + /// The name of the tab of the panel, default is `None`. + /// + /// Used to display in the already collapsed tab panel. + fn tab_name(&self, cx: &App) -> Option { + None + } + + /// The title of the panel + fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + t("Dock.Unnamed") + } + + /// The theme of the panel title, default is `None`. + fn title_style(&self, cx: &App) -> Option { + None + } + + /// The suffix of the panel title, default is `None`. + /// + /// This is used to add a suffix element to the panel title. + fn title_suffix( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + None:: + } + + /// Whether the panel can be closed, default is `true`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn closable(&self, cx: &App) -> bool { + true + } + + /// Return `PanelControl` if the panel is zoomable, default is `PanelControl::Menu`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn zoomable(&self, cx: &App) -> Option { + Some(PanelControl::Menu) + } + + /// Return false to hide panel, true to show panel, default is `true`. + /// + /// This method called in Panel render, we should make sure it is fast. + fn visible(&self, cx: &App) -> bool { + true + } + + /// Set active state of the panel. + /// + /// Called with the frame-end net state when this panel becomes (or stops + /// being) the displayed tab of its tab group: exactly one notification + /// per edge, delivered on the next tick after the change — never + /// same-value repeats nor false→true flips within one frame. + /// + /// A panel removed from its group is NOT told `false`; [`Panel::on_removed`] + /// is the deactivation signal. A hidden panel occupying `active_ix` still + /// receives `true` even though rendering falls back to the first visible + /// panel, and panels inside a bare `DockItem::Panel` (no tab group) are + /// outside this contract. + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) {} + + /// Set zoomed state of the panel. + /// + /// This method will be called when the panel is zoomed or unzoomed. + /// + /// Only current Panel will touch this method. + fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context) {} + + /// When this Panel is added to a TabPanel, this will be called. + fn on_added_to( + &mut self, + tab_panel: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + } + + /// When this Panel is removed from a TabPanel, this will be called. + fn on_removed(&mut self, window: &mut Window, cx: &mut Context) {} + + /// The addition dropdown menu of the panel, default is `None`. + fn dropdown_menu( + &mut self, + this: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + this + } + + /// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`. + fn toolbar_buttons( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + None + } + + /// Dump the panel, used to serialize the panel. + fn dump(&self, cx: &App) -> PanelState { + PanelState::new(self) + } + + /// Whether the panel has inner padding when the panel is in the tabs layout, default is `true`. + fn inner_padding(&self, cx: &App) -> bool { + true + } +} + +/// The PanelView trait used to define the panel view. +#[allow(unused_variables)] +pub trait PanelView: 'static + Send + Sync { + fn panel_name(&self, cx: &App) -> &'static str; + fn panel_id(&self, cx: &App) -> EntityId; + fn tab_name(&self, cx: &App) -> Option; + fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement; + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option; + fn title_style(&self, cx: &App) -> Option; + fn closable(&self, cx: &App) -> bool; + fn zoomable(&self, cx: &App) -> Option; + fn visible(&self, cx: &App) -> bool; + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App); + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App); + fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App); + fn on_removed(&self, window: &mut Window, cx: &mut App); + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu; + fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option>; + fn view(&self) -> AnyView; + fn focus_handle(&self, cx: &App) -> FocusHandle; + fn dump(&self, cx: &App) -> PanelState; + fn inner_padding(&self, cx: &App) -> bool; +} + +impl PanelView for Entity { + fn panel_name(&self, cx: &App) -> &'static str { + self.read(cx).panel_name() + } + + fn panel_id(&self, _: &App) -> EntityId { + self.entity_id() + } + + fn tab_name(&self, cx: &App) -> Option { + self.read(cx).tab_name(cx) + } + + fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement { + self.update(cx, |this, cx| this.title(window, cx).into_any_element()) + } + + fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option { + self.update(cx, |this, cx| { + this.title_suffix(window, cx) + .map(|el| el.into_any_element()) + }) + } + + fn title_style(&self, cx: &App) -> Option { + self.read(cx).title_style(cx) + } + + fn closable(&self, cx: &App) -> bool { + self.read(cx).closable(cx) + } + + fn zoomable(&self, cx: &App) -> Option { + self.read(cx).zoomable(cx) + } + + fn visible(&self, cx: &App) -> bool { + self.read(cx).visible(cx) + } + + fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_active(active, window, cx); + }) + } + + fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| { + this.set_zoomed(zoomed, window, cx); + }) + } + + fn on_added_to(&self, tab_panel: WeakEntity, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_added_to(tab_panel, window, cx)); + } + + fn on_removed(&self, window: &mut Window, cx: &mut App) { + self.update(cx, |this, cx| this.on_removed(window, cx)); + } + + fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu { + self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx)) + } + + fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option> { + self.update(cx, |this, cx| this.toolbar_buttons(window, cx)) + } + + fn view(&self) -> AnyView { + self.clone().into() + } + + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.read(cx).focus_handle(cx) + } + + fn dump(&self, cx: &App) -> PanelState { + self.read(cx).dump(cx) + } + + fn inner_padding(&self, cx: &App) -> bool { + self.read(cx).inner_padding(cx) + } +} + +impl From<&dyn PanelView> for AnyView { + fn from(handle: &dyn PanelView) -> Self { + handle.view() + } +} + +impl From<&dyn PanelView> for Entity { + fn from(value: &dyn PanelView) -> Self { + value.view().downcast::().unwrap() + } +} + +impl PartialEq for dyn PanelView { + fn eq(&self, other: &Self) -> bool { + self.view() == other.view() + } +} + +/// The deserializer used by [`PanelRegistry`] to rebuild a panel from its +/// persisted [`PanelState`]. +type PanelBuilder = dyn Fn( + WeakEntity, + &PanelState, + &PanelInfo, + &mut Window, + &mut App, +) -> Box; + +pub struct PanelRegistry { + pub(super) items: HashMap>, +} +impl PanelRegistry { + /// Initialize the panel registry. + pub(crate) fn init(cx: &mut App) { + if cx.try_global::().is_none() { + cx.set_global(PanelRegistry::new()); + } + } + + pub fn new() -> Self { + Self { + items: HashMap::new(), + } + } + + pub fn global(cx: &App) -> &Self { + cx.global::() + } + + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } + + /// Build a panel by name. + /// + /// If not registered, return InvalidPanel. + pub fn build_panel( + panel_name: &str, + dock_area: WeakEntity, + panel_state: &PanelState, + panel_info: &PanelInfo, + window: &mut Window, + cx: &mut App, + ) -> Box { + if let Some(view) = Self::global(cx) + .items + .get(panel_name) + .cloned() + .map(|f| f(dock_area, panel_state, panel_info, window, cx)) + { + view + } else { + // Show an invalid panel if the panel is not registered. + Box::new(cx.new(|cx| InvalidPanel::new(panel_name, panel_state.clone(), window, cx))) + } + } +} +impl Default for PanelRegistry { + fn default() -> Self { + Self::new() + } +} +impl Global for PanelRegistry {} + +/// Register the Panel init by panel_name to global registry. +pub fn register_panel(cx: &mut App, panel_name: &str, deserialize: F) +where + F: Fn( + WeakEntity, + &PanelState, + &PanelInfo, + &mut Window, + &mut App, + ) -> Box + + 'static, +{ + PanelRegistry::init(cx); + PanelRegistry::global_mut(cx) + .items + .insert(panel_name.to_string(), Arc::new(deserialize)); +} diff --git a/crates/dock/src/resize_handle.rs b/crates/dock/src/resize_handle.rs new file mode 100644 index 0000000..d60fdb3 --- /dev/null +++ b/crates/dock/src/resize_handle.rs @@ -0,0 +1,232 @@ +//! Vendored from `gpui-base`'s private `resizable::resize_handle` module +//! (v0.5.2, rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902). gpui-component +//! keeps this and [`PANEL_MIN_SIZE`] crate-private, so the dock crate carries +//! its own copy. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement, + IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, + StatefulInteractiveElement, Styled as _, Window, div, px, +}; +use gpui_component::{ActiveTheme as _, AxisExt as _, Side}; + +pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.); +pub(crate) const HANDLE_PADDING: Pixels = px(4.); +pub(crate) const HANDLE_SIZE: Pixels = px(1.); + +/// Create a resize handle for a resizable panel. +#[doc(hidden)] +pub fn resize_handle( + id: impl Into, + axis: Axis, +) -> ResizeHandle { + ResizeHandle::new(id, axis) +} + +type DragHandler = dyn Fn(&Point, &mut Window, &mut App) -> Entity; + +#[doc(hidden)] +pub struct ResizeHandle { + id: ElementId, + axis: Axis, + drag_value: Option>, + placement: Option, + on_drag: Option>>, +} + +impl ResizeHandle { + fn new(id: impl Into, axis: Axis) -> Self { + let id = id.into(); + Self { + id: id.clone(), + on_drag: None, + drag_value: None, + placement: None, + axis, + } + } + + pub fn on_drag( + mut self, + value: T, + f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, + ) -> Self { + let value = Rc::new(value); + self.drag_value = Some(value.clone()); + self.on_drag = Some(Rc::new(move |p, window, cx| { + f(value.clone(), p, window, cx) + })); + self + } + + pub fn placement(mut self, placement: Side) -> Self { + self.placement = Some(placement); + self + } +} + +#[derive(Default, Debug, Clone)] +struct ResizeHandleState { + active: Cell, +} + +impl ResizeHandleState { + fn set_active(&self, active: bool) { + self.active.set(active); + } + + fn is_active(&self) -> bool { + self.active.get() + } +} + +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for ResizeHandle { + type PrepaintState = (); + type RequestLayoutState = AnyElement; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let neg_offset = -HANDLE_PADDING; + let axis = self.axis; + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + let bg_color = if state.is_active() { + cx.theme().drag_border + } else { + cx.theme().border + }; + + let mut el = div() + .id(self.id.clone()) + .occlude() + .absolute() + .flex_shrink_0() + .group("handle") + .when_some(self.on_drag.clone(), |this, on_drag| { + this.on_drag( + self.drag_value.clone().unwrap(), + move |_, position, window, cx| on_drag(&position, window, cx), + ) + }) + .map(|this| match self.placement { + Some(Side::Left) => { + // Special for Left Dock + // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING) + this.cursor_col_resize() + .top_0() + .right(px(1.)) + .h_full() + .w(HANDLE_SIZE) + .pl(HANDLE_PADDING) + } + _ => this + .when(axis.is_horizontal(), |this| { + this.cursor_col_resize() + .top_0() + .left(neg_offset) + .h_full() + .w(HANDLE_SIZE) + .px(HANDLE_PADDING) + }) + .when(axis.is_vertical(), |this| { + this.cursor_row_resize() + .top(neg_offset) + .left_0() + .w_full() + .h(HANDLE_SIZE) + .py(HANDLE_PADDING) + }), + }) + .child( + div() + .bg(bg_color) + .group_hover("handle", |this| this.bg(bg_color)) + .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) + .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)), + ) + .into_any_element(); + + let layout_id = el.request_layout(window, cx); + + ((layout_id, el), state) + }) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + request_layout.prepaint(window, cx); + } + + fn paint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + request_layout.paint(window, cx); + + window.with_element_state(id.unwrap(), |state: Option, window| { + let state = state.unwrap_or_default(); + + window.on_mouse_event({ + let state = state.clone(); + move |ev: &MouseDownEvent, phase, window, _| { + if bounds.contains(&ev.position) && phase.bubble() { + state.set_active(true); + window.refresh(); + } + } + }); + + window.on_mouse_event({ + let state = state.clone(); + move |_: &MouseUpEvent, _, window, _| { + if state.is_active() { + state.set_active(false); + window.refresh(); + } + } + }); + + ((), state) + }); + } +} diff --git a/crates/dock/src/stack_panel.rs b/crates/dock/src/stack_panel.rs new file mode 100644 index 0000000..5d64a55 --- /dev/null +++ b/crates/dock/src/stack_panel.rs @@ -0,0 +1,429 @@ +use std::sync::Arc; + +use gpui::{ + App, AppContext as _, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, WeakEntity, + Window, +}; +use gpui_component::{ + ActiveTheme, AxisExt as _, Placement, ResizablePanelEvent, ResizablePanelGroup, ResizableState, + h_flex, resizable_panel, +}; +use smallvec::SmallVec; + +use super::{DockArea, Panel, PanelEvent, PanelState, PanelView, TabPanel}; +use crate::PanelInfo; +use crate::resize_handle::PANEL_MIN_SIZE; + +pub struct StackPanel { + pub(super) parent: Option>, + pub(super) axis: Axis, + focus_handle: FocusHandle, + pub(crate) panels: SmallVec<[Arc; 2]>, + state: Entity, + _subscriptions: Vec, +} + +impl Panel for StackPanel { + fn panel_name(&self) -> &'static str { + "StackPanel" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + "StackPanel" + } + + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + for panel in &self.panels { + panel.set_active(active, window, cx); + } + } + + fn dump(&self, cx: &App) -> PanelState { + let sizes = self.state.read(cx).sizes().clone(); + let mut state = PanelState::new(self); + state.info = PanelInfo::stack(sizes, self.axis); + for panel in &self.panels { + state.add_child(panel.dump(cx)); + } + + state + } +} + +impl StackPanel { + pub fn new(axis: Axis, _: &mut Window, cx: &mut Context) -> Self { + let state = cx.new(|_| ResizableState::default()); + + let _subscriptions = vec![ + // Bubble up the resize event. + cx.subscribe(&state, |_, _, _: &ResizablePanelEvent, cx| { + cx.emit(PanelEvent::LayoutChanged) + }), + ]; + + Self { + axis, + parent: None, + focus_handle: cx.focus_handle(), + panels: SmallVec::new(), + state, + _subscriptions, + } + } + + /// The first level of the stack panel is root, will not have a parent. + fn is_root(&self) -> bool { + self.parent.is_none() + } + + /// Return true if self or parent only have last panel. + pub(super) fn is_last_panel(&self, cx: &App) -> bool { + if self.panels.len() > 1 { + return false; + } + + if let Some(parent) = &self.parent + && let Some(parent) = parent.upgrade() + { + return parent.read(cx).is_last_panel(cx); + } + + true + } + + pub(super) fn panels_len(&self) -> usize { + self.panels.len() + } + + /// Return the index of the panel. + pub(crate) fn index_of_panel(&self, panel: Arc) -> Option { + self.panels.iter().position(|p| p == &panel) + } + + fn assert_panel_is_valid(&self, panel: &Arc) { + assert!( + panel.view().downcast::().is_ok() + || panel.view().downcast::().is_ok(), + "Panel must be a `TabPanel` or `StackPanel`" + ); + } + + /// Add a panel at the end of the stack. + /// + /// If `size` is `None`, the panel will be given the average size of all panels in the stack. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn add_panel( + &mut self, + panel: Arc, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx); + } + + /// Add a panel at the [`Placement`]. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn add_panel_at( + &mut self, + panel: Arc, + placement: Placement, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel_at( + panel, + self.panels_len(), + placement, + size, + dock_area, + window, + cx, + ); + } + + /// Insert a panel at the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + #[allow(clippy::too_many_arguments)] + pub fn insert_panel_at( + &mut self, + panel: Arc, + ix: usize, + placement: Placement, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + match placement { + Placement::Top | Placement::Left => { + self.insert_panel_before(panel, ix, size, dock_area, window, cx) + } + Placement::Right | Placement::Bottom => { + self.insert_panel_after(panel, ix, size, dock_area, window, cx) + } + } + } + + /// Insert a panel at the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn insert_panel_before( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, ix, size, dock_area, window, cx); + } + + /// Insert a panel after the index. + /// + /// The `panel` must be a [`TabPanel`] or [`StackPanel`]. + pub fn insert_panel_after( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.insert_panel(panel, ix + 1, size, dock_area, window, cx); + } + + fn insert_panel( + &mut self, + panel: Arc, + ix: usize, + size: Option, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.assert_panel_is_valid(&panel); + + // If the panel is already in the stack, return. + if self.index_of_panel(panel.clone()).is_some() { + return; + } + + let view = cx.entity().clone(); + window.defer(cx, { + let panel = panel.clone(); + + move |window, cx| { + // If the panel is a TabPanel, set its parent to this. + if let Ok(tab_panel) = panel.view().downcast::() { + tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade())); + } else if let Ok(stack_panel) = panel.view().downcast::() { + stack_panel.update(cx, |stack_panel, _| { + stack_panel.parent = Some(view.downgrade()) + }); + } + + // Subscribe to the panel's layout change event. + _ = dock_area.update(cx, |this, cx| { + if let Ok(tab_panel) = panel.view().downcast::() { + this.subscribe_panel(&tab_panel, window, cx); + } else if let Ok(stack_panel) = panel.view().downcast::() { + this.subscribe_panel(&stack_panel, window, cx); + } + }); + } + }); + + let ix = if ix > self.panels.len() { + self.panels.len() + } else { + ix + }; + + // Get avg size of all panels to insert new panel, if size is None. + let size = match size { + Some(size) => size, + None => { + let state = self.state.read(cx); + (state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE) + } + }; + + self.panels.insert(ix, panel.clone()); + self.state.update(cx, |state, cx| { + state.insert_panel(Some(size), Some(ix), cx); + }); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Remove panel from the stack. + /// + /// If `ix` is not found, do nothing. + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.index_of_panel(panel.clone()) else { + return; + }; + + self.panels.remove(ix); + self.state.update(cx, |state, cx| { + state.remove_panel(ix, cx); + }); + + cx.emit(PanelEvent::LayoutChanged); + self.remove_self_if_empty(window, cx); + } + + /// Replace the old panel with the new panel at same index. + pub(super) fn replace_panel( + &mut self, + old_panel: Arc, + new_panel: Entity, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(ix) = self.index_of_panel(old_panel.clone()) { + self.panels[ix] = Arc::new(new_panel.clone()); + + self.state.update(cx, |state, cx| { + state.reset_panel(ix, cx); + }); + cx.emit(PanelEvent::LayoutChanged); + } + } + + /// If children is empty, remove self from parent view. + pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context) { + if self.is_root() { + return; + } + + if !self.panels.is_empty() { + return; + } + + let view = cx.entity().clone(); + if let Some(parent) = self.parent.as_ref() { + _ = parent.update(cx, |parent, cx| { + parent.remove_panel(Arc::new(view.clone()), window, cx); + }); + } + + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Find the first top left in the stack. + pub(super) fn left_top_tab_panel( + &self, + check_parent: bool, + cx: &App, + ) -> Option> { + if check_parent + && let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) + && let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) + { + return Some(panel); + } + + let first_panel = self.panels.first(); + if let Some(view) = first_panel { + if let Ok(tab_panel) = view.view().downcast::() { + Some(tab_panel) + } else if let Ok(stack_panel) = view.view().downcast::() { + stack_panel.read(cx).left_top_tab_panel(false, cx) + } else { + None + } + } else { + None + } + } + + /// Find the first top right in the stack. + pub(super) fn right_top_tab_panel( + &self, + check_parent: bool, + cx: &App, + ) -> Option> { + if check_parent + && let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) + && let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) + { + return Some(panel); + } + + let panel = if self.axis.is_vertical() { + self.panels.first() + } else { + self.panels.last() + }; + + if let Some(view) = panel { + if let Ok(tab_panel) = view.view().downcast::() { + Some(tab_panel) + } else if let Ok(stack_panel) = view.view().downcast::() { + stack_panel.read(cx).right_top_tab_panel(false, cx) + } else { + None + } + } else { + None + } + } + + /// Remove all panels from the stack. + pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { + self.panels.clear(); + self.state.update(cx, |state, cx| { + state.clear(); + cx.notify(); + }); + } + + /// Change the axis of the stack panel. + pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { + self.axis = axis; + cx.notify(); + } +} + +impl Focusable for StackPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl EventEmitter for StackPanel {} +impl EventEmitter for StackPanel {} +impl Render for StackPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .overflow_hidden() + .bg(cx.theme().tokens.tab_bar) + .child( + ResizablePanelGroup::new("stack-panel-group") + .with_state(&self.state) + .axis(self.axis) + .children(self.panels.clone().into_iter().map(|panel| { + resizable_panel() + .child(panel.view()) + .visible(panel.visible(cx)) + })), + ) + } +} diff --git a/crates/dock/src/state.rs b/crates/dock/src/state.rs new file mode 100644 index 0000000..acfc406 --- /dev/null +++ b/crates/dock/src/state.rs @@ -0,0 +1,282 @@ +use gpui::{App, AppContext, Axis, Bounds, Entity, Pixels, WeakEntity, Window, point, px, size}; +use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; + +use super::{Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry}; + +/// Used to serialize and deserialize the DockArea +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockAreaState { + /// The version is used to mark this persisted state is compatible with the current version + /// For example, some times we many totally changed the structure of the Panel, + /// then we can compare the version to decide whether we can use the state or ignore. + #[serde(default)] + pub version: Option, + pub center: PanelState, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_dock: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bottom_dock: Option, +} + +/// Used to serialize and deserialize the Dock +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DockState { + panel: PanelState, + placement: DockPlacement, + size: Pixels, + open: bool, +} + +impl DockState { + pub fn new(dock: Entity, cx: &App) -> Self { + let dock = dock.read(cx); + + Self { + placement: dock.placement, + size: dock.size, + open: dock.open, + panel: dock.panel.view().dump(cx), + } + } + + /// Convert the DockState to Dock + pub fn to_dock( + &self, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let item = self.panel.to_item(dock_area.clone(), window, cx); + cx.new(|cx| { + Dock::from_state( + dock_area.clone(), + self.placement, + self.size, + item, + self.open, + window, + cx, + ) + }) + } +} + +/// Used to serialize and deserialize the DockerItem +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PanelState { + pub panel_name: String, + pub children: Vec, + pub info: PanelInfo, +} + +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +pub struct TileMeta { + pub bounds: Bounds, + pub z_index: usize, +} + +impl Default for TileMeta { + fn default() -> Self { + Self { + bounds: Bounds { + origin: point(px(10.), px(10.)), + size: size(px(200.), px(200.)), + }, + z_index: 0, + } + } +} + +impl From> for TileMeta { + fn from(bounds: Bounds) -> Self { + Self { bounds, z_index: 0 } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PanelInfo { + #[serde(rename = "stack")] + Stack { + sizes: Vec, + axis: usize, // 0 for horizontal, 1 for vertical + }, + #[serde(rename = "tabs")] + Tabs { active_index: usize }, + #[serde(rename = "panel")] + Panel(serde_json::Value), + #[serde(rename = "tiles")] + Tiles { metas: Vec }, +} + +impl PanelInfo { + pub fn stack(sizes: Vec, axis: Axis) -> Self { + Self::Stack { + sizes, + axis: if axis == Axis::Horizontal { 0 } else { 1 }, + } + } + + pub fn tabs(active_index: usize) -> Self { + Self::Tabs { active_index } + } + + pub fn panel(info: serde_json::Value) -> Self { + Self::Panel(info) + } + + pub fn tiles(metas: Vec) -> Self { + Self::Tiles { metas } + } + + pub fn axis(&self) -> Option { + match self { + Self::Stack { axis, .. } => Some(if *axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }), + _ => None, + } + } + + pub fn sizes(&self) -> Option<&Vec> { + match self { + Self::Stack { sizes, .. } => Some(sizes), + _ => None, + } + } + + pub fn active_index(&self) -> Option { + match self { + Self::Tabs { active_index } => Some(*active_index), + _ => None, + } + } +} + +impl Default for PanelState { + fn default() -> Self { + Self { + panel_name: "".to_string(), + children: Vec::new(), + info: PanelInfo::Panel(serde_json::Value::Null), + } + } +} + +impl PanelState { + pub fn new(panel: &P) -> Self { + Self { + panel_name: panel.panel_name().to_string(), + ..Default::default() + } + } + + pub fn add_child(&mut self, panel: PanelState) { + self.children.push(panel); + } + + pub fn to_item( + &self, + dock_area: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> DockItem { + let info = self.info.clone(); + + let items: Vec = self + .children + .iter() + .map(|child| child.to_item(dock_area.clone(), window, cx)) + .collect(); + + match info { + PanelInfo::Stack { sizes, axis } => { + let axis = if axis == 0 { + Axis::Horizontal + } else { + Axis::Vertical + }; + let sizes = sizes.iter().map(|s| Some(*s)).collect_vec(); + DockItem::split_with_sizes(axis, items, sizes, &dock_area, window, cx) + } + PanelInfo::Tabs { active_index } => { + if items.len() == 1 { + return items[0].clone(); + } + + let items = items + .iter() + .flat_map(|item| match item { + DockItem::Tabs { items, .. } => items.clone(), + _ => { + // ignore invalid panels in tabs + vec![] + } + }) + .collect_vec(); + + DockItem::tabs(items, &dock_area, window, cx).active_index(active_index, cx) + } + PanelInfo::Panel(_) => { + let view = PanelRegistry::build_panel( + &self.panel_name, + dock_area.clone(), + self, + &info, + window, + cx, + ); + DockItem::tabs(vec![view.into()], &dock_area, window, cx) + } + PanelInfo::Tiles { metas } => DockItem::tiles(items, metas, &dock_area, window, cx), + } + } +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::*; + #[test] + fn test_deserialize_item_state() { + let json = include_str!("fixtures/layout.json"); + let state: DockAreaState = serde_json::from_str(json).unwrap(); + assert_eq!(state.version, None); + assert_eq!(state.center.panel_name, "StackPanel"); + assert_eq!(state.center.children.len(), 2); + assert_eq!(state.center.children[0].panel_name, "TabPanel"); + assert_eq!(state.center.children[1].children.len(), 1); + assert_eq!( + state.center.children[1].children[0].panel_name, + "StoryContainer" + ); + assert_eq!(state.center.children[1].panel_name, "TabPanel"); + + let left_dock = state.left_dock.unwrap(); + assert!(left_dock.open); + assert_eq!(left_dock.size, px(350.0)); + assert_eq!(left_dock.placement, DockPlacement::Left); + assert_eq!(left_dock.panel.panel_name, "TabPanel"); + assert_eq!(left_dock.panel.children.len(), 1); + assert_eq!(left_dock.panel.children[0].panel_name, "StoryContainer"); + + let bottom_dock = state.bottom_dock.unwrap(); + assert!(bottom_dock.open); + assert_eq!(bottom_dock.size, px(200.0)); + assert_eq!(bottom_dock.panel.panel_name, "TabPanel"); + assert_eq!(bottom_dock.panel.children.len(), 2); + assert_eq!(bottom_dock.panel.children[0].panel_name, "StoryContainer"); + + let right_dock = state.right_dock.unwrap(); + assert!(right_dock.open); + assert_eq!(right_dock.size, px(320.0)); + assert_eq!(right_dock.panel.panel_name, "TabPanel"); + assert_eq!(right_dock.panel.children.len(), 1); + assert_eq!(right_dock.panel.children[0].panel_name, "StoryContainer"); + } +} diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs new file mode 100644 index 0000000..713038a --- /dev/null +++ b/crates/dock/src/tab_panel.rs @@ -0,0 +1,2102 @@ +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use gpui::prelude::FluentBuilder; +use gpui::{ + Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div, + DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, ScrollHandle, + SharedString, StatefulInteractiveElement, StyleRefinement, Styled, WeakEntity, Window, div, + point, px, rems, +}; +use gpui_component::animation::{Lerp, ease_out_cubic}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::menu::{DropdownMenu, PopupMenu}; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::{ + ActiveTheme, AxisExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, +}; + +use super::{ + AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl, + PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom, +}; +use crate::{PanelInfo, t}; + +#[derive(Clone)] +struct TabState { + closable: bool, + zoomable: Option, + draggable: bool, + droppable: bool, + active_panel: Option>, +} + +#[derive(Clone)] +pub(crate) struct DragPanel { + pub(crate) panel: Arc, + pub(crate) tab_panel: Entity, + drag_offset: Rc>>, + drag_session_id: u64, +} + +static NEXT_DRAG_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Stands in for [`DragPanel::drag_session_id`] on host-owned drag items, which +/// carry no session of their own. `NEXT_DRAG_SESSION_ID` starts at 1, so 0 never +/// collides. +const ITEM_DRAG_SESSION_ID: u64 = 0; + +impl DragPanel { + pub(crate) fn new(panel: Arc, tab_panel: Entity) -> Self { + Self { + panel, + tab_panel, + drag_offset: Rc::new(Cell::new(Point::default())), + drag_session_id: NEXT_DRAG_SESSION_ID.fetch_add(1, Ordering::Relaxed), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct DropPlaceholderBounds { + origin: Point, + size: gpui::Size, +} + +impl DropPlaceholderBounds { + fn for_placement(bounds: gpui::Bounds, placement: Option) -> Self { + let half_width = bounds.size.width * 0.5; + let half_height = bounds.size.height * 0.5; + + match placement { + Some(Placement::Left) => Self { + origin: Point::default(), + size: gpui::size(half_width, bounds.size.height), + }, + Some(Placement::Right) => Self { + origin: point(half_width, px(0.)), + size: gpui::size(half_width, bounds.size.height), + }, + Some(Placement::Top) => Self { + origin: Point::default(), + size: gpui::size(bounds.size.width, half_height), + }, + Some(Placement::Bottom) => Self { + origin: point(px(0.), half_height), + size: gpui::size(bounds.size.width, half_height), + }, + None => Self { + origin: Point::default(), + size: bounds.size, + }, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct DropPlaceholderAnimation { + drag_session_id: u64, + placement: Option, + from: DropPlaceholderBounds, + to: DropPlaceholderBounds, + epoch: u64, +} + +impl Render for DragPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("drag-panel") + .cursor_grab() + .py_1() + .px_3() + .w_24() + .overflow_hidden() + .whitespace_nowrap() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .text_color(cx.theme().tab_foreground) + .bg(cx.theme().tokens.tab_active) + .opacity(0.75) + .child(self.panel.title(window, cx)) + } +} + +pub struct TabPanel { + focus_handle: FocusHandle, + dock_area: WeakEntity, + /// The stock_panel can be None, if is None, that means the panels can't be split or move + stack_panel: Option>, + pub(crate) panels: Vec>, + pub(crate) active_ix: usize, + /// What each panel was last told via `set_active`, keyed by EntityId; absent means `false`. + notified_active: HashMap, + /// Whether an active-state reconcile task is already queued for this frame. + active_sync_scheduled: bool, + /// If this is true, the Panel closable will follow the active panel's closable, + /// otherwise this TabPanel will not able to close + /// + /// This is used for Dock to limit the last TabPanel not able to close, see [`super::Dock::new`]. + pub(crate) closable: bool, + + tab_bar_scroll_handle: ScrollHandle, + pending_scroll_to_ix: Option, + zoomed: bool, + collapsed: bool, + /// When drag move, will get the placement of the panel to be split + will_split_placement: Option, + drop_placeholder_animation: Option, + drop_placeholder_animation_name: SharedString, + /// Is TabPanel used in Tiles. + in_tiles: bool, +} + +impl Panel for TabPanel { + fn panel_name(&self) -> &'static str { + "TabPanel" + } + + fn title(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.active_panel(cx) + .map(|panel| panel.title(window, cx)) + .unwrap_or("Empty Tab".into_any_element()) + } + + fn closable(&self, cx: &App) -> bool { + if !self.closable { + return false; + } + + // 1. When is the final panel in the dock, it will not able to close. + // 2. When is in the Tiles, it will always able to close (by active panel state). + if !self.draggable(cx) && !self.in_tiles { + return false; + } + + self.active_panel(cx) + .map(|panel| panel.closable(cx)) + .unwrap_or(false) + } + + fn zoomable(&self, cx: &App) -> Option { + self.active_panel(cx).and_then(|panel| panel.zoomable(cx)) + } + + fn visible(&self, cx: &App) -> bool { + self.visible_panels(cx).next().is_some() + } + + fn dropdown_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + if let Some(panel) = self.active_panel(cx) { + panel.dropdown_menu(menu, window, cx) + } else { + menu + } + } + + fn toolbar_buttons( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + self.active_panel(cx) + .and_then(|panel| panel.toolbar_buttons(window, cx)) + } + + fn dump(&self, cx: &App) -> PanelState { + let mut state = PanelState::new(self); + for panel in self.panels.iter() { + state.add_child(panel.dump(cx)); + state.info = PanelInfo::tabs(self.active_ix); + } + state + } + + fn inner_padding(&self, cx: &App) -> bool { + self.active_panel(cx) + .is_none_or(|panel| panel.inner_padding(cx)) + } +} + +impl TabPanel { + pub fn new( + stack_panel: Option>, + dock_area: WeakEntity, + _: &mut Window, + cx: &mut Context, + ) -> Self { + let entity_id = cx.entity_id(); + Self { + focus_handle: cx.focus_handle(), + dock_area, + stack_panel, + panels: Vec::new(), + active_ix: 0, + notified_active: HashMap::new(), + active_sync_scheduled: false, + tab_bar_scroll_handle: ScrollHandle::new(), + pending_scroll_to_ix: None, + will_split_placement: None, + drop_placeholder_animation: None, + drop_placeholder_animation_name: format!("dock-drop-placeholder-{entity_id}").into(), + zoomed: false, + collapsed: false, + closable: true, + in_tiles: false, + } + } + + /// Mark the TabPanel as being used in Tiles. + pub(super) fn set_in_tiles(&mut self, in_tiles: bool) { + self.in_tiles = in_tiles; + } + + pub(super) fn set_parent(&mut self, view: WeakEntity) { + self.stack_panel = Some(view); + } + + /// Return current active_panel View + pub fn active_panel(&self, cx: &App) -> Option> { + let panel = self.panels.get(self.active_ix); + + if let Some(panel) = panel { + if panel.visible(cx) { + Some(panel.clone()) + } else { + // Return the first visible panel + self.visible_panels(cx).next() + } + } else { + None + } + } + + pub fn active_ix(&self) -> usize { + self.active_ix + } + + fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix == self.active_ix { + return; + } + + self.active_ix = ix; + self.pending_scroll_to_ix = Some(ix); + self.focus_active_panel(window, cx); + self.schedule_active_sync(window, cx); + + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Queue one reconcile task per frame that notifies panels of their + /// frame-end net active state. Using a spawned task (not `defer`) is what + /// guarantees the task runs after every same-frame mutation, including + /// deferred `set_collapsed` from [`super::Dock::set_open`]. + fn schedule_active_sync(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_sync_scheduled { + return; + } + self.active_sync_scheduled = true; + + cx.spawn_in(window, async move |view, cx| { + _ = cx.update(|window, cx| { + let Ok(changes) = view.update(cx, |view, _| view.reconcile_active_states()) else { + return; + }; + // Dispatch outside the TabPanel update so a `set_active` + // handler may call back into this TabPanel without panicking. + for (panel, active) in changes { + panel.set_active(active, window, cx); + } + }); + }) + .detach(); + } + + /// Diff every panel's target state (`ix == active_ix && !collapsed`) + /// against what it was last told, returning the deliveries to make — + /// all `false` first, the single `true` last. Panels no longer in the + /// group are pruned without a `false`: `on_removed` is their signal. + fn reconcile_active_states(&mut self) -> Vec<(Arc, bool)> { + self.active_sync_scheduled = false; + + let mut notified = HashMap::with_capacity(self.panels.len()); + let mut changes = Vec::new(); + let mut activated = None; + for (ix, panel) in self.panels.iter().enumerate() { + let id = panel.view().entity_id(); + let target = ix == self.active_ix && !self.collapsed; + let last = self.notified_active.get(&id).copied().unwrap_or(false); + if target != last { + if target { + activated = Some((panel.clone(), true)); + } else { + changes.push((panel.clone(), false)); + } + } + notified.insert(id, target); + } + self.notified_active = notified; + changes.extend(activated); + changes + } + + /// Add a panel to the end of the tabs + pub fn add_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.add_panel_with_active(panel, true, window, cx); + } + + fn add_panel_with_active( + &mut self, + panel: Arc, + active: bool, + window: &mut Window, + cx: &mut Context, + ) { + assert_ne!( + panel.panel_name(cx), + "StackPanel", + "can not allows add `StackPanel` to `TabPanel`" + ); + + if self + .panels + .iter() + .any(|p| p.view().entity_id() == panel.view().entity_id()) + { + return; + } + + panel.on_added_to(cx.entity().downgrade(), window, cx); + self.panels.push(panel); + // set the active panel to the new panel + if active { + self.set_active_ix(self.panels.len() - 1, window, cx); + } + // Unconditional: set_active_ix early-returns for the first panel, + // which is displayed regardless of `active`. + self.schedule_active_sync(window, cx); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Add panel to try to split + pub fn add_panel_at( + &mut self, + panel: Arc, + placement: Placement, + size: Option, + window: &mut Window, + cx: &mut Context, + ) { + cx.spawn_in(window, async move |view, cx| { + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.will_split_placement = Some(placement); + view.split_panel(panel, placement, size, None, window, cx) + }) + .ok() + }) + .ok() + }) + .detach(); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + fn insert_panel_at( + &mut self, + panel: Arc, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + if self + .panels + .iter() + .any(|p| p.view().entity_id() == panel.view().entity_id()) + { + return; + } + + panel.on_added_to(cx.entity().downgrade(), window, cx); + self.panels.insert(ix, panel); + self.set_active_ix(ix, window, cx); + // set_active_ix early-returns when ix == active_ix, yet the + // displayed panel just changed. + self.schedule_active_sync(window, cx); + cx.emit(PanelEvent::LayoutChanged); + cx.notify(); + } + + /// Remove a panel from the tab panel + pub fn remove_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) { + self.detach_panel(panel, window, cx); + self.remove_self_if_empty(window, cx); + cx.emit(PanelEvent::ZoomOut); + cx.emit(PanelEvent::LayoutChanged); + } + + /// Detach the panel, returning what it was last told via `set_active` so + /// drag-and-drop can carry that belief into the target `TabPanel`. + fn detach_panel( + &mut self, + panel: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Option { + panel.on_removed(window, cx); + let panel_view = panel.view(); + let removed_ix = self.panels.iter().position(|p| p.view() == panel_view); + self.panels.retain(|p| p.view() != panel_view); + // Keep following the same displayed panel. + if removed_ix.is_some_and(|ix| ix < self.active_ix) { + self.active_ix -= 1; + } + if self.active_ix >= self.panels.len() { + self.set_active_ix(self.panels.len().saturating_sub(1), window, cx) + } + self.schedule_active_sync(window, cx); + self.notified_active.remove(&panel_view.entity_id()) + } + + /// Check to remove self from the parent StackPanel, if there is no panel left + fn remove_self_if_empty(&self, window: &mut Window, cx: &mut Context) { + if !self.panels.is_empty() { + return; + } + + let tab_view = cx.entity().clone(); + if let Some(stack_panel) = self.stack_panel.as_ref() { + _ = stack_panel.update(cx, |view, cx| { + view.remove_panel(Arc::new(tab_view), window, cx); + }); + } + } + + pub(super) fn set_collapsed( + &mut self, + collapsed: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.collapsed = collapsed; + self.schedule_active_sync(window, cx); + cx.notify(); + } + + fn is_locked(&self, cx: &App) -> bool { + let Some(dock_area) = self.dock_area.upgrade() else { + return true; + }; + + if dock_area.read(cx).is_locked() { + return true; + } + + if self.zoomed { + return true; + } + + self.stack_panel.is_none() + } + + /// Return true if self or parent only have last panel. + /// + /// Only visible panels are counted, so a hidden panel does not keep the + /// last visible panel draggable/closable (which could otherwise leave the + /// dock visually empty and undroppable). + fn is_last_panel(&self, cx: &App) -> bool { + if let Some(parent) = &self.stack_panel + && let Some(stack_panel) = parent.upgrade() + && !stack_panel.read(cx).is_last_panel(cx) + { + return false; + } + + self.visible_panels(cx).count() <= 1 + } + + /// Return all visible panels + fn visible_panels<'a>(&'a self, cx: &'a App) -> impl Iterator> + 'a { + self.panels.iter().filter_map(|panel| { + if panel.visible(cx) { + Some(panel.clone()) + } else { + None + } + }) + } + + /// Return true if the tab panel is draggable. + /// + /// E.g. if the parent and self only have one panel, it is not draggable. + fn draggable(&self, cx: &App) -> bool { + !self.is_locked(cx) && !self.is_last_panel(cx) + } + + /// Return true if the tab panel is droppable. + /// + /// E.g. if the tab panel is locked, it is not droppable. + fn droppable(&self, cx: &App) -> bool { + !self.is_locked(cx) + } + + fn render_toolbar( + &mut self, + state: &TabState, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + if self.collapsed { + return div(); + } + + let zoomed = self.zoomed; + let view = cx.entity().clone(); + let zoomable_toolbar_visible = state.zoomable.is_some_and(|v| v.toolbar_visible()); + + h_flex() + .gap_1() + .occlude() + .when_some(self.toolbar_buttons(window, cx), |this, buttons| { + this.children( + buttons + .into_iter() + .map(|btn| btn.xsmall().ghost().tab_stop(false)), + ) + }) + .map(|this| { + let value = if zoomed { + Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))) + } else if zoomable_toolbar_visible { + Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))) + } else { + None + }; + + if let Some((id, icon, tooltip)) = value { + this.child( + Button::new(id) + .icon(icon) + .xsmall() + .ghost() + .tab_stop(false) + .tooltip_with_action(tooltip, &ToggleZoom, None) + .selected(zoomed) + .on_click(cx.listener(|view, _, window, cx| { + view.on_action_toggle_zoom(&ToggleZoom, window, cx) + })), + ) + } else { + this + } + }) + .child( + Button::new("menu") + .icon(IconName::Ellipsis) + .xsmall() + .ghost() + .tab_stop(false) + .dropdown_menu({ + let zoomable = state.zoomable.is_some_and(|v| v.menu_visible()); + let closable = state.closable; + + move |menu, window, cx| { + view.update(cx, |this, cx| { + this.dropdown_menu(menu, window, cx) + .separator() + .menu_with_disabled( + if zoomed { + t("Dock.Zoom Out") + } else { + t("Dock.Zoom In") + }, + Box::new(ToggleZoom), + !zoomable, + ) + .when(closable, |this| { + this.separator().menu(t("Dock.Close"), Box::new(ClosePanel)) + }) + }) + } + }) + .anchor(Anchor::TopRight), + ) + } + + fn render_dock_toggle_button( + &self, + placement: DockPlacement, + _: &mut Window, + cx: &mut Context, + ) -> Option