From f6ce53ef9c001bd95c53c6474e5f01cf7bf3e1ed Mon Sep 17 00:00:00 2001 From: reya Date: Thu, 19 Feb 2026 07:25:07 +0000 Subject: [PATCH] feat: revamp the chat panel ui (#7) Reviewed-on: https://git.reya.su/reya/coop/pulls/7 --- Cargo.lock | 176 +++--- assets/icons/paper-plane-fill.svg | 3 + crates/auto_update/src/lib.rs | 10 +- crates/chat/src/lib.rs | 236 +++----- crates/chat/src/message.rs | 4 +- crates/chat/src/room.rs | 298 ++++++++-- crates/chat_ui/Cargo.toml | 3 +- crates/chat_ui/src/actions.rs | 7 + crates/chat_ui/src/emoji.rs | 139 ----- crates/chat_ui/src/lib.rs | 700 ++++++++++++----------- crates/coop/Cargo.toml | 2 +- crates/coop/src/main.rs | 22 +- crates/coop/src/sidebar/entry.rs | 8 - crates/coop/src/sidebar/mod.rs | 20 +- crates/coop/src/workspace.rs | 4 +- crates/device/src/lib.rs | 362 ++++++------ crates/dock/src/panel.rs | 2 +- crates/dock/src/tab_panel.rs | 4 +- crates/person/src/lib.rs | 40 +- crates/person/src/person.rs | 23 + crates/relay_auth/src/lib.rs | 244 ++++---- crates/settings/src/lib.rs | 28 +- crates/state/src/constants.rs | 2 + crates/state/src/device.rs | 62 -- crates/state/src/event.rs | 46 -- crates/state/src/lib.rs | 354 ++++++------ crates/state/src/signer.rs | 25 +- crates/theme/src/scrollbar_mode.rs | 2 +- crates/ui/src/anchored.rs | 333 +++++++++++ crates/ui/src/dropdown.rs | 811 --------------------------- crates/ui/src/geometry.rs | 294 ++++++++++ crates/ui/src/icon.rs | 2 + crates/ui/src/index_path.rs | 69 +++ crates/ui/src/lib.rs | 9 +- crates/ui/src/list/cache.rs | 221 ++++++++ crates/ui/src/list/delegate.rs | 171 ++++++ crates/ui/src/list/list.rs | 782 +++++++++++++++----------- crates/ui/src/list/list_item.rs | 119 ++-- crates/ui/src/list/loading.rs | 2 +- crates/ui/src/list/mod.rs | 21 + crates/ui/src/list/separator_item.rs | 50 ++ crates/ui/src/menu/app_menu_bar.rs | 98 ++-- crates/ui/src/menu/context_menu.rs | 228 +++++--- crates/ui/src/menu/dropdown_menu.rs | 142 +++++ crates/ui/src/menu/menu_item.rs | 24 +- crates/ui/src/menu/mod.rs | 9 +- crates/ui/src/menu/popup_menu.rs | 664 ++++++++++++++-------- crates/ui/src/modal.rs | 7 +- crates/ui/src/popover.rs | 714 +++++++++++------------ crates/ui/src/scroll/scrollable.rs | 367 ++++++------ crates/ui/src/scroll/scrollbar.rs | 299 ++++++---- crates/ui/src/skeleton.rs | 4 +- crates/ui/src/styled.rs | 85 +-- 53 files changed, 4613 insertions(+), 3738 deletions(-) create mode 100644 assets/icons/paper-plane-fill.svg delete mode 100644 crates/chat_ui/src/emoji.rs delete mode 100644 crates/state/src/device.rs delete mode 100644 crates/state/src/event.rs create mode 100644 crates/ui/src/anchored.rs delete mode 100644 crates/ui/src/dropdown.rs create mode 100644 crates/ui/src/geometry.rs create mode 100644 crates/ui/src/index_path.rs create mode 100644 crates/ui/src/list/cache.rs create mode 100644 crates/ui/src/list/delegate.rs create mode 100644 crates/ui/src/list/separator_item.rs create mode 100644 crates/ui/src/menu/dropdown_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 36164c7..66118fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,9 +286,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -298,9 +298,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -605,9 +605,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -812,9 +812,9 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" [[package]] name = "bytemuck" @@ -1009,10 +1009,9 @@ dependencies = [ "chat", "common", "dock", - "emojis", + "flume", "gpui", "gpui_tokio", - "indexset", "itertools 0.13.0", "log", "nostr-sdk", @@ -1073,9 +1072,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -1083,9 +1082,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -1194,7 +1193,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1253,9 +1252,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "deflate64", @@ -1498,9 +1497,9 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b" +checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56" dependencies = [ "bitflags 2.11.0", "fontdb", @@ -1639,7 +1638,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "proc-macro2", "quote", @@ -1848,15 +1847,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "emojis" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" -dependencies = [ - "phf", -] - [[package]] name = "encoding_rs" version = "0.8.35" @@ -2194,7 +2184,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.9.9", + "memmap2 0.9.10", "slotmap", "tinyvec", "ttf-parser", @@ -2299,9 +2289,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2314,9 +2304,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2324,15 +2314,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2341,9 +2331,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -2375,9 +2365,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -2386,21 +2376,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2410,7 +2400,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2599,7 +2588,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2699,7 +2688,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2710,7 +2699,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "gpui", @@ -2939,7 +2928,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "async-compression", @@ -2964,7 +2953,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3202,7 +3191,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png 0.18.0", + "png 0.18.1", "qoi", "ravif", "rayon", @@ -3722,6 +3711,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3745,7 +3743,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "bindgen", @@ -3774,9 +3772,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", ] @@ -3910,9 +3908,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -4004,7 +4002,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "aes", "base64", @@ -4029,7 +4027,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", "futures-core", @@ -4042,7 +4040,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "btreecap", "flatbuffers", @@ -4054,7 +4052,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "nostr", ] @@ -4062,7 +4060,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "indexmap", "lru", @@ -4074,7 +4072,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", "flume", @@ -4088,7 +4086,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4" +source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c" dependencies = [ "async-utility", "async-wsocket", @@ -4562,7 +4560,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "collections", "serde", @@ -4711,9 +4709,9 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.11.0", "crc32fast", @@ -5238,7 +5236,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "derive_refineable", ] @@ -5343,7 +5341,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "bytes", @@ -5398,7 +5396,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "arrayvec", "log", @@ -5660,7 +5658,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "async-task", "backtrace", @@ -6241,7 +6239,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "arrayvec", "log", @@ -6357,9 +6355,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -6837,9 +6835,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.8+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -6951,10 +6949,14 @@ version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -7065,9 +7067,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -7197,7 +7199,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "async-fs", @@ -7235,7 +7237,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "perf", "quote", @@ -8704,7 +8706,7 @@ checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" dependencies = [ "as-raw-xcb-connection", "libc", - "memmap2 0.9.9", + "memmap2 0.9.10", "xkeysym", ] @@ -9043,7 +9045,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "anyhow", "chrono", @@ -9060,7 +9062,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" dependencies = [ "tracing", "tracing-subscriber", @@ -9071,7 +9073,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043" +source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8" [[package]] name = "zune-core" diff --git a/assets/icons/paper-plane-fill.svg b/assets/icons/paper-plane-fill.svg new file mode 100644 index 0000000..8231630 --- /dev/null +++ b/assets/icons/paper-plane-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index f680a0a..3bb10a6 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -231,13 +231,13 @@ impl AutoUpdater { fn subscribe_to_updates(cx: &App) -> Task<()> { let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); + let _client = nostr.read(cx).client(); cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); - let filter = Filter::new() + let _filter = Filter::new() .kind(Kind::ReleaseArtifactSet) .author(app_pubkey) .limit(1); @@ -253,7 +253,7 @@ impl AutoUpdater { }); cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let _opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); let filter = Filter::new() @@ -274,7 +274,7 @@ impl AutoUpdater { // Get all file metadata event ids let ids: Vec = event.tags.event_ids().copied().collect(); - let filter = Filter::new() + let _filter = Filter::new() .kind(Kind::FileMetadata) .author(app_pubkey) .ids(ids.clone()); diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 43b6448..ab4b494 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -7,16 +7,14 @@ use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::EventUtils; -use device::DeviceRegistry; -use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, + App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, }; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; +use state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP}; mod message; mod room; @@ -24,8 +22,8 @@ mod room; pub use message::*; pub use room::*; -pub fn init(cx: &mut App) { - ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); +pub fn init(window: &mut Window, cx: &mut App) { + ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); } struct GlobalChatRegistry(Entity); @@ -45,11 +43,9 @@ pub enum ChatEvent { /// Channel signal. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum NostrEvent { +enum Signal { /// Message received from relay pool Message(NewMessage), - /// Unwrapping status - Unwrapping(bool), /// Eose received from relay pool Eose, } @@ -60,23 +56,11 @@ pub struct ChatRegistry { /// Collection of all chat rooms rooms: Vec>, - /// Loading status of the registry - loading: bool, - - /// Channel's sender for communication between nostr and gpui - sender: Sender, - /// Tracking the status of unwrapping gift wrap events. tracking_flag: Arc, - /// Handle tracking asynchronous task - tracking: Option>>, - - /// Handle notifications asynchronous task - notifications: Option>, - - /// Tasks for asynchronous operations - tasks: Vec>, + /// Async tasks + tasks: SmallVec<[Task>; 2]>, /// Subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -96,82 +80,38 @@ impl ChatRegistry { } /// Create a new chat registry instance - fn new(cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); - let nip17_state = nostr.read(cx).nip17_state(); + let nip65 = nostr.read(cx).nip65_state(); + let nip17 = nostr.read(cx).nip17_state(); - let device = DeviceRegistry::global(cx); - let device_signer = device.read(cx).device_signer.clone(); - - // A flag to indicate if the registry is loading - let tracking_flag = Arc::new(AtomicBool::new(false)); - - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(2048); - - let mut tasks = vec![]; let mut subscriptions = smallvec![]; subscriptions.push( - // Observe the identity - cx.observe(&nip17_state, |this, state, cx| { - if state.read(cx) == &RelayState::Configured { - // Handle nostr notifications - this.handle_notifications(cx); - // Track unwrapping progress - this.tracking(cx); + // Observe the nip65 state and load chat rooms on every state change + cx.observe(&nip65, |this, state, cx| { + if state.read(cx).idle() { + this.reset(cx); } - // Get chat rooms from the database on every identity change + }), + ); + + subscriptions.push( + // Observe the nip17 state and load chat rooms on every state change + cx.observe(&nip17, |this, _state, cx| { this.get_rooms(cx); }), ); - subscriptions.push( - // Observe the device signer state - cx.observe(&device_signer, |this, state, cx| { - if state.read(cx).is_some() { - this.handle_notifications(cx); - } - }), - ); - - tasks.push( - // Update GPUI states - cx.spawn(async move |this, cx| { - while let Ok(message) = rx.recv_async().await { - match message { - NostrEvent::Message(message) => { - this.update(cx, |this, cx| { - this.new_message(message, cx); - }) - .ok(); - } - NostrEvent::Unwrapping(status) => { - this.update(cx, |this, cx| { - this.set_loading(status, cx); - this.get_rooms(cx); - }) - .ok(); - } - NostrEvent::Eose => { - this.update(cx, |this, cx| { - this.get_rooms(cx); - }) - .ok(); - } - }; - } - }), - ); + cx.defer_in(window, |this, _window, cx| { + this.handle_notifications(cx); + this.tracking(cx); + }); Self { rooms: vec![], - loading: false, - sender: tx.clone(), - tracking_flag, - tracking: None, - notifications: None, - tasks, + tracking_flag: Arc::new(AtomicBool::new(false)), + tasks: smallvec![], _subscriptions: subscriptions, } } @@ -180,18 +120,18 @@ impl ChatRegistry { fn handle_notifications(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - - let device = DeviceRegistry::global(cx); - let device_signer = device.read(cx).signer(cx); - + let signer = nostr.read(cx).signer(); let status = self.tracking_flag.clone(); - let tx = self.sender.clone(); - self.notifications = Some(cx.background_spawn(async move { - let initialized_at = Timestamp::now(); - let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); - let sub_id2 = SubscriptionId::new(USER_GIFTWRAP); + let initialized_at = Timestamp::now(); + let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP); + let sub_id2 = SubscriptionId::new(USER_GIFTWRAP); + // Channel for communication between nostr and gpui + let (tx, rx) = flume::bounded::(1024); + + self.tasks.push(cx.background_spawn(async move { + let device_signer = signer.get_encryption_signer().await; let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); @@ -213,23 +153,16 @@ impl ChatRegistry { continue; } + log::info!("Received gift wrap event: {:?}", event); + // Extract the rumor from the gift wrap event match Self::extract_rumor(&client, &device_signer, event.as_ref()).await { Ok(rumor) => match rumor.created_at >= initialized_at { true => { - // Check if the event is sent by coop - let sent_by_coop = { - let tracker = tracker().read().await; - tracker.is_sent_by_coop(&event.id) - }; - // No need to emit if sent by coop - // the event is already emitted - if !sent_by_coop { - let new_message = NewMessage::new(event.id, rumor); - let signal = NostrEvent::Message(new_message); + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); - tx.send_async(signal).await.ok(); - } + tx.send_async(signal).await?; } false => { status.store(true, Ordering::Release); @@ -242,29 +175,46 @@ impl ChatRegistry { } RelayMessage::EndOfStoredEvents(id) => { if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 { - tx.send_async(NostrEvent::Eose).await.ok(); + tx.send_async(Signal::Eose).await?; } } _ => {} } } + + Ok(()) + })); + + self.tasks.push(cx.spawn(async move |this, cx| { + while let Ok(message) = rx.recv_async().await { + match message { + Signal::Message(message) => { + this.update(cx, |this, cx| { + this.new_message(message, cx); + })?; + } + Signal::Eose => { + this.update(cx, |this, cx| { + this.get_rooms(cx); + })?; + } + }; + } + + Ok(()) })); } /// Tracking the status of unwrapping gift wrap events. fn tracking(&mut self, cx: &mut Context) { let status = self.tracking_flag.clone(); - let tx = self.sender.clone(); - self.tracking = Some(cx.background_spawn(async move { - let loop_duration = Duration::from_secs(12); + self.tasks.push(cx.background_spawn(async move { + let loop_duration = Duration::from_secs(10); loop { if status.load(Ordering::Acquire) { _ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed); - tx.send_async(NostrEvent::Unwrapping(true)).await.ok(); - } else { - tx.send_async(NostrEvent::Unwrapping(false)).await.ok(); } smol::Timer::after(loop_duration).await; } @@ -273,13 +223,7 @@ impl ChatRegistry { /// Get the loading status of the chat registry pub fn loading(&self) -> bool { - self.loading - } - - /// Set the loading status of the chat registry - pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { - self.loading = loading; - cx.notify(); + self.tracking_flag.load(Ordering::Acquire) } /// Get a weak reference to a room by its ID. @@ -315,19 +259,19 @@ impl ChatRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - self.tasks.push(cx.spawn(async move |this, cx| { - if let Some(signer) = client.signer() { - if let Ok(public_key) = signer.get_public_key().await { - this.update(cx, |this, cx| { - this.rooms - .insert(0, cx.new(|_| room.into().organize(&public_key))); - cx.emit(ChatEvent::Ping); - cx.notify(); - }) - .ok(); - } - } - })); + cx.spawn(async move |this, cx| { + let signer = client.signer()?; + let public_key = signer.get_public_key().await.ok()?; + let room: Room = room.into().organize(&public_key); + + this.update(cx, |this, cx| { + this.rooms.insert(0, cx.new(|_| room)); + cx.emit(ChatEvent::Ping); + cx.notify(); + }) + .ok() + }) + .detach(); } /// Emit an open room event. @@ -420,20 +364,16 @@ impl ChatRegistry { pub fn get_rooms(&mut self, cx: &mut Context) { let task = self.get_rooms_from_database(cx); - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(rooms) => { - this.update(cx, move |this, cx| { - this.extend_rooms(rooms, cx); - this.sort(cx); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to load rooms: {e}") - } - }; - })); + cx.spawn(async move |this, cx| { + let rooms = task.await.ok()?; + + this.update(cx, move |this, cx| { + this.extend_rooms(rooms, cx); + this.sort(cx); + }) + .ok() + }) + .detach(); } /// Create a task to load rooms from the database diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 5ec33a7..6118331 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -6,8 +6,8 @@ use nostr_sdk::prelude::*; /// New message. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct NewMessage { - pub gift_wrap: EventId, pub room: u64, + pub gift_wrap: EventId, pub rumor: UnsignedEvent, } @@ -16,8 +16,8 @@ impl NewMessage { let room = rumor.uniq_id(); Self { - gift_wrap, room, + gift_wrap, rumor, } } diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index a09cfea..029debd 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -1,5 +1,4 @@ use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::time::Duration; @@ -9,73 +8,59 @@ use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; -use state::{tracker, NostrRegistry}; +use settings::{RoomConfig, SignerKind}; +use state::{NostrRegistry, TIMEOUT}; use crate::{ChatRegistry, NewMessage}; -const SEND_RETRY: usize = 10; - #[derive(Debug, Clone)] pub struct SendReport { pub receiver: PublicKey, - pub status: Option>, + pub gift_wrap_id: Option, pub error: Option, - pub on_hold: Option, - pub encryption: bool, - pub relays_not_found: bool, - pub device_not_found: bool, + pub output: Option>, } impl SendReport { pub fn new(receiver: PublicKey) -> Self { Self { receiver, - status: None, + gift_wrap_id: None, error: None, - on_hold: None, - encryption: false, - relays_not_found: false, - device_not_found: false, + output: None, } } - pub fn status(mut self, output: Output) -> Self { - self.status = Some(output); + /// Set the gift wrap ID. + pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self { + self.gift_wrap_id = Some(gift_wrap_id); self } - pub fn error(mut self, error: impl Into) -> Self { + /// Set the output. + pub fn output(mut self, output: Output) -> Self { + self.output = Some(output); + self + } + + /// Set the error message. + pub fn error(mut self, error: T) -> Self + where + T: Into, + { self.error = Some(error.into()); self } - pub fn on_hold(mut self, event: Event) -> Self { - self.on_hold = Some(event); - self + /// Returns true if the send is pending. + pub fn pending(&self) -> bool { + self.output.is_none() && self.error.is_none() } - pub fn encryption(mut self) -> Self { - self.encryption = true; - self - } - - pub fn relays_not_found(mut self) -> Self { - self.relays_not_found = true; - self - } - - pub fn device_not_found(mut self) -> Self { - self.device_not_found = true; - self - } - - pub fn is_relay_error(&self) -> bool { - self.error.is_some() || self.relays_not_found - } - - pub fn is_sent_success(&self) -> bool { - if let Some(output) = self.status.as_ref() { - !output.success.is_empty() + /// Returns true if the send was successful. + pub fn success(&self) -> bool { + if let Some(output) = self.output.as_ref() { + !output.failed.is_empty() } else { false } @@ -115,6 +100,9 @@ pub struct Room { /// Kind pub kind: RoomKind, + + /// Configuration + config: RoomConfig, } impl Ord for Room { @@ -161,6 +149,7 @@ impl From<&UnsignedEvent> for Room { subject, members, kind: RoomKind::default(), + config: RoomConfig::default(), } } } @@ -320,34 +309,43 @@ impl Room { } /// Get gossip relays for each member - pub fn connect(&self, cx: &App) -> Task> { + pub fn early_connect(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let members = self.members(); - let id = SubscriptionId::new(format!("room-{}", self.id)); + let subscription_id = SubscriptionId::new(format!("room-{}", self.id)); cx.background_spawn(async move { let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; - // Subscription options - let opts = SubscribeAutoCloseOptions::default() - .timeout(Some(Duration::from_secs(2))) - .exit_policy(ReqExitPolicy::ExitOnEOSE); - for member in members.into_iter() { if member == public_key { continue; }; - // Construct a filter for gossip relays - let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1); + // Construct a filter for messaging relays + let inbox = Filter::new() + .kind(Kind::InboxRelays) + .author(member) + .limit(1); + + // Construct a filter for announcement + let announcement = Filter::new() + .kind(Kind::Custom(10044)) + .author(member) + .limit(1); // Subscribe to get member's gossip relays client - .subscribe(filter) - .close_on(opts) - .with_id(id.clone()) + .subscribe(vec![inbox, announcement]) + .with_id(subscription_id.clone()) + .close_on( + SubscribeAutoCloseOptions::default() + .timeout(Some(Duration::from_secs(TIMEOUT))) + .exit_policy(ReqExitPolicy::ExitOnEOSE), + ) .await?; } @@ -379,7 +377,194 @@ impl Room { }) } - /// Create a new unsigned message event + // Construct a rumor event for direct message + pub fn rumor(&self, content: S, replies: I, cx: &App) -> Option + where + S: Into, + I: IntoIterator, + { + let kind = Kind::PrivateDirectMessage; + let content: String = content.into(); + let replies: Vec = replies.into_iter().collect(); + + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + + // Get current user's public key + let sender = nostr.read(cx).signer().public_key()?; + + // Get all members + let members: Vec = self + .members + .iter() + .filter(|public_key| public_key != &&sender) + .map(|member| persons.read(cx).get(member, cx)) + .collect(); + + // Construct event's tags + let mut tags = vec![]; + + // Add subject tag if present + if let Some(value) = self.subject.as_ref() { + tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( + value.to_string(), + ))); + } + + // Add all reply tags + for id in replies.into_iter() { + tags.push(Tag::event(id)) + } + + // Add all receiver tags + for member in members.into_iter() { + // Skip current user + if member.public_key() == sender { + continue; + } + + tags.push(Tag::from_standardized_without_cell( + TagStandard::PublicKey { + public_key: member.public_key(), + relay_url: member.messaging_relay_hint(), + alias: None, + uppercase: false, + }, + )); + } + + // Construct a direct message rumor event + // WARNING: never sign and send this event to relays + let mut event = EventBuilder::new(kind, content).tags(tags).build(sender); + + // Ensure that the ID is set + event.ensure_id(); + + Some(event) + } + + /// Send rumor event to all members's messaging relays + pub fn send(&self, rumor: UnsignedEvent, cx: &App) -> Option>> { + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + // Get room's config + let config = self.config.clone(); + + // Get current user's public key + let sender = nostr.read(cx).signer().public_key()?; + + // Get all members (excluding sender) + let members: Vec = self + .members + .iter() + .filter(|public_key| public_key != &&sender) + .map(|member| persons.read(cx).get(member, cx)) + .collect(); + + Some(cx.background_spawn(async move { + let signer_kind = config.signer_kind(); + let user_signer = signer.get().await; + let encryption_signer = signer.get_encryption_signer().await; + + let mut reports = Vec::new(); + + for member in members { + let relays = member.messaging_relays(); + let announcement = member.announcement(); + + // Skip if member has no messaging relays + if relays.is_empty() { + reports.push(SendReport::new(member.public_key()).error("No messaging relays")); + continue; + } + + // Ensure relay connections + for url in relays.iter() { + client + .add_relay(url) + .and_connect() + .capabilities(RelayCapabilities::GOSSIP) + .await + .ok(); + } + + // When forced to use encryption signer, skip if receiver has no announcement + if signer_kind.encryption() && announcement.is_none() { + reports + .push(SendReport::new(member.public_key()).error("Encryption not found")); + continue; + } + + // Determine receiver and signer based on signer kind + let (receiver, signer_to_use) = match signer_kind { + SignerKind::Auto => { + if let Some(announcement) = announcement { + if let Some(enc_signer) = encryption_signer.as_ref() { + (announcement.public_key(), enc_signer.clone()) + } else { + (member.public_key(), user_signer.clone()) + } + } else { + (member.public_key(), user_signer.clone()) + } + } + SignerKind::Encryption => { + let Some(encryption_signer) = encryption_signer.as_ref() else { + reports.push( + SendReport::new(member.public_key()).error("Encryption not found"), + ); + continue; + }; + let Some(announcement) = announcement else { + reports.push( + SendReport::new(member.public_key()) + .error("Announcement not found"), + ); + continue; + }; + (announcement.public_key(), encryption_signer.clone()) + } + SignerKind::User => (member.public_key(), user_signer.clone()), + }; + + // Create and send gift-wrapped event + match EventBuilder::gift_wrap(&signer_to_use, &receiver, rumor.clone(), []).await { + Ok(event) => { + match client + .send_event(&event) + .to(relays) + .ack_policy(AckPolicy::none()) + .await + { + Ok(output) => { + reports.push( + SendReport::new(member.public_key()) + .gift_wrap_id(event.id) + .output(output), + ); + } + Err(e) => { + reports.push( + SendReport::new(member.public_key()).error(e.to_string()), + ); + } + } + } + Err(e) => { + reports.push(SendReport::new(member.public_key()).error(e.to_string())); + } + } + } + + reports + })) + } + + /* + * /// Create a new unsigned message event pub fn create_message( &self, content: &str, @@ -444,7 +629,7 @@ impl Room { // WARNING: never sign and send this event to relays let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content) .tags(tags) - .build(Keys::generate().public_key()); + .build(public_key); // Ensure the event ID has been generated event.ensure_id(); @@ -594,4 +779,5 @@ impl Room { Ok(resend_reports) }) } + */ } diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 9f99fc0..fef8a71 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -22,11 +22,10 @@ anyhow.workspace = true itertools.workspace = true smallvec.workspace = true smol.workspace = true +flume.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true -indexset = "0.12.3" -emojis = "0.6.4" once_cell = "1.19.0" regex = "1" diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index bea282e..ab28139 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -2,6 +2,13 @@ use gpui::Action; use nostr_sdk::prelude::*; use serde::Deserialize; +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = chat, no_json)] +pub enum Command { + Insert(&'static str), + ChangeSubject(&'static str), +} + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = chat, no_json)] pub struct SeenOn(pub EventId); diff --git a/crates/chat_ui/src/emoji.rs b/crates/chat_ui/src/emoji.rs deleted file mode 100644 index c60aeeb..0000000 --- a/crates/chat_ui/src/emoji.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::sync::OnceLock; - -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, -}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::InputState; -use ui::popover::{Popover, PopoverContent}; -use ui::{Icon, Sizable, Size}; - -static EMOJIS: OnceLock> = OnceLock::new(); - -fn get_emojis() -> &'static Vec { - EMOJIS.get_or_init(|| { - let mut emojis: Vec = vec![]; - - emojis.extend( - emojis::Group::SmileysAndEmotion - .emojis() - .map(|e| SharedString::from(e.as_str())) - .collect::>(), - ); - - emojis - }) -} - -#[derive(IntoElement)] -pub struct EmojiPicker { - target: Option>, - icon: Option, - anchor: Option, - size: Size, -} - -impl EmojiPicker { - pub fn new() -> Self { - Self { - size: Size::default(), - target: None, - anchor: None, - icon: None, - } - } - - pub fn target(mut self, target: WeakEntity) -> Self { - self.target = Some(target); - self - } - - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - #[allow(dead_code)] - pub fn anchor(mut self, corner: Corner) -> Self { - self.anchor = Some(corner); - self - } -} - -impl Sizable for EmojiPicker { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for EmojiPicker { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - Popover::new("emojis") - .map(|this| { - if let Some(corner) = self.anchor { - this.anchor(corner) - } else { - this.anchor(gpui::Corner::BottomLeft) - } - }) - .trigger( - Button::new("emojis-trigger") - .when_some(self.icon, |this, icon| this.icon(icon)) - .ghost() - .with_size(self.size), - ) - .content(move |window, cx| { - let input = self.target.clone(); - - cx.new(|cx| { - PopoverContent::new(window, cx, move |_window, cx| { - div() - .flex() - .flex_wrap() - .items_center() - .gap_2() - .children(get_emojis().iter().map(|e| { - div() - .id(e.clone()) - .flex_auto() - .size_10() - .flex() - .items_center() - .justify_center() - .rounded(cx.theme().radius) - .child(e.clone()) - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .on_click({ - let item = e.clone(); - let input = input.clone(); - - move |_, window, cx| { - if let Some(input) = input.as_ref() { - _ = input.update(cx, |this, cx| { - let value = this.value(); - let new_text = if value.is_empty() { - format!("{item}") - } else if value.ends_with(" ") { - format!("{value}{item}") - } else { - format!("{value} {item}") - }; - this.set_value(new_text, window, cx); - }); - } - } - }) - })) - .into_any() - }) - .scrollable() - .max_h(px(300.)) - .max_w(px(300.)) - }) - }) - } -} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a29ec1c..bb46fe4 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,44 +1,44 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; pub use actions::*; -use anyhow::Error; -use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport}; +use anyhow::{Context as AnyhowContext, Error}; +use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport}; use common::{nip96_upload, RenderedTimestamp}; use dock::panel::{Panel, PanelEvent}; use gpui::prelude::FluentBuilder; use gpui::{ - div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, + deferred, div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, - PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, - Styled, StyledImage, Subscription, Task, WeakEntity, Window, + PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage, + Subscription, Task, WeakEntity, Window, }; use gpui_tokio::Tokio; -use indexset::{BTreeMap, BTreeSet}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::fs; +use smol::lock::RwLock; use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::context_menu::ContextMenuExt; +use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; +use ui::menu::{ContextMenuExt, DropdownMenu}; use ui::notification::Notification; -use ui::popup_menu::PopupMenuExt; +use ui::scroll::Scrollbar; use ui::{ h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, }; -use crate::emoji::EmojiPicker; use crate::text::RenderedText; mod actions; -mod emoji; mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -49,7 +49,6 @@ pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity pub struct ChatPanel { id: SharedString, focus_handle: FocusHandle, - image_cache: Entity, /// Chat Room room: WeakEntity, @@ -63,12 +62,15 @@ pub struct ChatPanel { /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, - /// Mapping message ids to their reports - reports_by_id: BTreeMap>, + /// Mapping message (rumor event) ids to their reports + reports_by_id: Entity>>, /// Input state input: Entity, + /// Sent message ids + sent_ids: Arc>>, + /// Replies to replies_to: Entity>, @@ -79,97 +81,63 @@ pub struct ChatPanel { uploading: bool, /// Async operations - tasks: SmallVec<[Task<()>; 2]>, + tasks: Vec>>, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 2]>, } impl ChatPanel { pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { + // Define attachments and replies_to entities + let attachments = cx.new(|_| vec![]); + let replies_to = cx.new(|_| HashSet::new()); + let reports_by_id = cx.new(|_| BTreeMap::new()); + + // Define list of messages + let messages = BTreeSet::from([Message::system()]); + let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); + + // Get room id and name + let (id, name) = room + .read_with(cx, |this, _cx| { + let id = this.id.to_string().into(); + let name = this.display_name(cx); + + (id, name) + }) + .unwrap_or(("Unknown".into(), "Message...".into())); + + // Define input state let input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Message...") + .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .prevent_new_line_on_enter() .clean_on_escape() }); - let attachments = cx.new(|_| vec![]); - let replies_to = cx.new(|_| HashSet::new()); - - let messages = BTreeSet::from([Message::system()]); - let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); - - let id: SharedString = room - .read_with(cx, |this, _cx| this.id.to_string().into()) - .unwrap_or("Unknown".into()); - - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) { - tasks.push( - // Get messaging relays and encryption keys announcement for each member - cx.background_spawn(async move { - if let Err(e) = connect.await { - log::error!("Failed to initialize room: {}", e); - } - }), - ); - }; - - if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) { - tasks.push( - // Load all messages belonging to this room - cx.spawn_in(window, async move |this, cx| { - let result = get_messages.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(events) => { - this.insert_messages(&events, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }), - ); - } - - if let Some(room) = room.upgrade() { - subscriptions.push( - // Subscribe to room events - cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { - match event { - RoomEvent::Incoming(message) => { - this.insert_message(message, false, cx); - } - RoomEvent::Reload => { - this.load_messages(window, cx); - } - }; - }), - ); - } - - subscriptions.push( - // Subscribe to input events - cx.subscribe_in( - &input, - window, - move |this: &mut Self, _input, event, window, cx| { + // Define subscriptions + let subscriptions = + smallvec![ + cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { - this.send_message(window, cx); + this.send_text_message(window, cx); }; - }, - ), - ); + }) + ]; + + // Define all functions that will run after the current cycle + cx.defer_in(window, |this, window, cx| { + this.connect(window, cx); + this.handle_notifications(cx); + + this.subscribe_room_events(window, cx); + this.get_messages(window, cx); + }); Self { + focus_handle: cx.focus_handle(), id, messages, room, @@ -178,38 +146,113 @@ impl ChatPanel { replies_to, attachments, rendered_texts_by_id: BTreeMap::new(), - reports_by_id: BTreeMap::new(), + reports_by_id, + sent_ids: Arc::new(RwLock::new(Vec::new())), uploading: false, - image_cache: RetainAllImageCache::new(cx), - focus_handle: cx.focus_handle(), - _subscriptions: subscriptions, - tasks, + subscriptions, + tasks: vec![], } } + /// Handle nostr notifications + fn handle_notifications(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let sent_ids = self.sent_ids.clone(); + + let (tx, rx) = flume::bounded::<(EventId, RelayUrl)>(256); + + self.tasks.push(cx.background_spawn(async move { + let mut notifications = client.notifications(); + + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { + message: RelayMessage::Ok { event_id, .. }, + relay_url, + } = notification + { + let sent_ids = sent_ids.read().await; + + if sent_ids.contains(&event_id) { + tx.send_async((event_id, relay_url)).await.ok(); + } + } + } + + Ok(()) + })); + + self.tasks.push(cx.spawn(async move |this, cx| { + while let Ok((event_id, relay_url)) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.reports_by_id.update(cx, |this, cx| { + for reports in this.values_mut() { + for report in reports.iter_mut() { + if let Some(output) = report.output.as_mut() { + if output.id() == &event_id { + output.success.insert(relay_url.clone()); + cx.notify(); + } + } + } + } + }); + })?; + } + + Ok(()) + })); + } + + fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context) { + let Some(room) = self.room.upgrade() else { + return; + }; + + self.subscriptions.push( + // Subscribe to room events + cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { + match event { + RoomEvent::Incoming(message) => { + this.insert_message(message, false, cx); + } + RoomEvent::Reload => { + this.get_messages(window, cx); + } + }; + }), + ); + } + + /// Get all necessary data for each member + fn connect(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(connect) = self.room.read_with(cx, |this, cx| this.early_connect(cx)) else { + return; + }; + + self.tasks.push(cx.background_spawn(connect)); + } + /// Load all messages belonging to this room - fn load_messages(&mut self, window: &mut Window, cx: &mut Context) { - if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) { - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = get_messages.await; + fn get_messages(&mut self, _window: &mut Window, cx: &mut Context) { + let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { + return; + }; - this.update_in(cx, |this, window, cx| { - match result { - Ok(events) => { - this.insert_messages(&events, cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - })); - } + self.tasks.push(cx.spawn(async move |this, cx| { + let events = get_messages.await?; + + // Update message list + this.update(cx, |this, cx| { + this.insert_messages(&events, cx); + })?; + + Ok(()) + })); } - /// Get user input content and merged all attachments - fn input_content(&self, cx: &Context) -> String { + /// Get user input content and merged all attachments if available + fn get_input_value(&self, cx: &Context) -> String { // Get input's value let mut content = self.input.read(cx).value().trim().to_string(); @@ -233,10 +276,9 @@ impl ChatPanel { content } - /// Send a message to all members of the chat - fn send_message(&mut self, window: &mut Window, cx: &mut Context) { + fn send_text_message(&mut self, window: &mut Window, cx: &mut Context) { // Get the message which includes all attachments - let content = self.input_content(cx); + let content = self.get_input_value(cx); // Return if message is empty if content.trim().is_empty() { @@ -244,80 +286,97 @@ impl ChatPanel { return; } - // Get replies_to if it's present - let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); + self.send_message(&content, window, cx); + } - // Get a task to create temporary message for optimistic update - let Ok(get_rumor) = self - .room - .read_with(cx, |this, cx| this.create_message(&content, replies, cx)) - else { + /// Send a message to all members of the chat + fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context) { + if value.trim().is_empty() { + window.push_notification("Cannot send an empty message", cx); return; - }; + } - // Optimistically update message list - let task: Task> = cx.spawn_in(window, async move |this, cx| { - let mut rumor = get_rumor.await?; - let rumor_id = rumor.id(); + // Get room entity + let room = self.room.clone(); + + // Get content and replies + let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); + let content = value.to_string(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = room.upgrade().context("Room is not available")?; - // Update the message list and reset the states this.update_in(cx, |this, window, cx| { - this.remove_all_replies(cx); - this.remove_all_attachments(cx); - - // Reset the input to its default state - this.input.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_disabled(false, cx); - this.set_value("", window, cx); - }); - - // Update the message list - this.insert_message(&rumor, true, cx); - - if let Ok(task) = this - .room - .read_with(cx, |this, cx| this.send_message(&rumor, cx)) - { - this.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(reports) => { - // Update room's status - this.room - .update(cx, |this, cx| { - if this.kind != RoomKind::Ongoing { - // Update the room kind to ongoing, - // but keep the room kind if send failed - if reports.iter().all(|r| !r.is_sent_success()) { - this.kind = RoomKind::Ongoing; - cx.notify(); - } - } - }) - .ok(); - - // Insert the sent reports - this.reports_by_id.insert(rumor_id, reports); - - cx.notify(); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - } - }) - .ok(); - })) + match room.read(cx).rumor(content, replies, cx) { + Some(rumor) => { + this.insert_message(&rumor, true, cx); + this.send_and_wait(rumor, window, cx); + this.clear(window, cx); + } + None => { + window.push_notification("Failed to create message", cx); + } } })?; Ok(()) - }); + })); + } - task.detach(); + /// Send message in the background and wait for the response + fn send_and_wait(&mut self, rumor: UnsignedEvent, window: &mut Window, cx: &mut Context) { + let sent_ids = self.sent_ids.clone(); + // This can't fail, because we already ensured that the ID is set + let id = rumor.id.unwrap(); + + let Some(room) = self.room.upgrade() else { + return; + }; + + let Some(task) = room.read(cx).send(rumor, cx) else { + window.push_notification("Failed to send message", cx); + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let outputs = task.await; + + // Add sent IDs to the list + let mut sent_ids = sent_ids.write().await; + sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id)); + + // Update the state + this.update(cx, |this, cx| { + this.insert_reports(id, outputs, cx); + })?; + + Ok(()) + })) + } + + /// Clear the input field, attachments, and replies + /// + /// Only run after sending a message + fn clear(&mut self, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + self.attachments.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + self.replies_to.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }) + } + + /// Insert reports + fn insert_reports(&mut self, id: EventId, reports: Vec, cx: &mut Context) { + self.reports_by_id.update(cx, |this, cx| { + this.insert(id, reports); + cx.notify(); + }); } /// Insert a message into the chat panel @@ -350,23 +409,33 @@ impl ChatPanel { } } - /// Check if a message failed to send by its ID - fn is_sent_failed(&self, id: &EventId) -> bool { + /// Check if a message is pending + fn sent_pending(&self, id: &EventId, cx: &App) -> bool { self.reports_by_id + .read(cx) .get(id) - .is_some_and(|reports| reports.iter().all(|r| !r.is_sent_success())) + .is_some_and(|reports| reports.iter().any(|r| r.pending())) } /// Check if a message was sent successfully by its ID - fn is_sent_success(&self, id: &EventId) -> Option { + fn sent_success(&self, id: &EventId, cx: &App) -> bool { self.reports_by_id + .read(cx) .get(id) - .map(|reports| reports.iter().all(|r| r.is_sent_success())) + .is_some_and(|reports| reports.iter().any(|r| r.success())) } - /// Get the sent reports for a message by its ID - fn sent_reports(&self, id: &EventId) -> Option<&Vec> { - self.reports_by_id.get(id) + /// Check if a message failed to send by its ID + fn sent_failed(&self, id: &EventId, cx: &App) -> Option { + self.reports_by_id + .read(cx) + .get(id) + .map(|reports| reports.iter().all(|r| !r.success())) + } + + /// Get all sent reports for a message by its ID + fn sent_reports(&self, id: &EventId, cx: &App) -> Option> { + self.reports_by_id.read(cx).get(id).cloned() } /// Get a message by its ID @@ -415,13 +484,6 @@ impl ChatPanel { }); } - fn remove_all_replies(&mut self, cx: &mut Context) { - self.replies_to.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - } - fn upload(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -436,9 +498,9 @@ impl ChatPanel { prompt: None, }); - cx.spawn_in(window, async move |this, cx| { - let mut paths = path.await.ok()?.ok()??; - let path = paths.pop()?; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let mut paths = path.await??.context("Not found")?; + let path = paths.pop().context("No path")?; let upload = Tokio::spawn(cx, async move { let file = fs::read(path).await.ok()?; @@ -467,9 +529,8 @@ impl ChatPanel { .ok(); } - Some(()) - }) - .detach(); + Ok(()) + })); } fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { @@ -493,28 +554,21 @@ impl ChatPanel { }); } - fn remove_all_attachments(&mut self, cx: &mut Context) { - self.attachments.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - } - fn profile(&self, public_key: &PublicKey, cx: &Context) -> Person { let persons = PersonRegistry::global(cx); persons.read(cx).get(public_key, cx) } fn render_announcement(&self, ix: usize, cx: &Context) -> AnyElement { + const MSG: &str = + "This conversation is private. Only members can see each other's messages."; + v_flex() .id(ix) - .group("") - .h_32() + .h_40() .w_full() - .relative() .gap_3() - .px_3() - .py_2() + .p_3() .items_center() .justify_center() .text_center() @@ -524,12 +578,10 @@ impl ChatPanel { .child( svg() .path("brand/coop.svg") - .size_10() - .text_color(cx.theme().elevated_surface_background), + .size_12() + .text_color(cx.theme().ghost_element_active), ) - .child(SharedString::from( - "This conversation is private. Only members can see each other's messages.", - )) + .child(SharedString::from(MSG)) .into_any_element() } @@ -567,7 +619,7 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) -> AnyElement { - if let Some(message) = self.messages.get_index(ix) { + if let Some(message) = self.messages.iter().nth(ix) { match message { Message::User(rendered) => { let text = self @@ -592,7 +644,7 @@ impl ChatPanel { &self, ix: usize, message: &RenderedMessage, - text: AnyElement, + rendered_text: AnyElement, cx: &Context, ) -> AnyElement { let id = message.id; @@ -603,10 +655,13 @@ impl ChatPanel { let has_replies = !replies.is_empty(); // Check if message is sent failed - let is_sent_failed = self.is_sent_failed(&id); + let sent_pending = self.sent_pending(&id, cx); // Check if message is sent successfully - let is_sent_success = self.is_sent_success(&id); + let sent_success = self.sent_success(&id, cx); + + // Check if message is sent failed + let sent_failed = self.sent_failed(&id, cx); // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -654,18 +709,21 @@ impl ChatPanel { .child(author.name()), ) .child(message.created_at.to_human_time()) - .when_some(is_sent_success, |this, status| { - this.when(status, |this| { - this.child(self.render_message_sent(&id, cx)) - }) + .when(sent_pending, |this| { + this.child(deferred(Indicator::new().small())) + }) + .when(sent_success, |this| { + this.child(deferred(self.render_sent_indicator(&id, cx))) }), ) .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) - .child(text) - .when(is_sent_failed, |this| { - this.child(self.render_message_reports(&id, cx)) + .child(rendered_text) + .when_some(sent_failed, |this, failed| { + this.when(failed, |this| { + this.child(deferred(self.render_message_reports(&id, cx))) + }) }), ), ) @@ -730,11 +788,11 @@ impl ChatPanel { items } - fn render_message_sent(&self, id: &EventId, _cx: &Context) -> impl IntoElement { + fn render_sent_indicator(&self, id: &EventId, cx: &Context) -> impl IntoElement { div() .id(SharedString::from(id.to_hex())) .child(SharedString::from("• Sent")) - .when_some(self.sent_reports(id).cloned(), |this, reports| { + .when_some(self.sent_reports(id, cx), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); @@ -766,7 +824,7 @@ impl ChatPanel { .child(SharedString::from( "Failed to send message. Click to see details.", )) - .when_some(self.sent_reports(id).cloned(), |this, reports| { + .when_some(self.sent_reports(id, cx), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); @@ -809,48 +867,6 @@ impl ChatPanel { .child(name.clone()), ), ) - .when(report.relays_not_found, |this| { - this.child( - h_flex() - .flex_wrap() - .justify_center() - .p_2() - .h_20() - .w_full() - .text_sm() - .rounded(cx.theme().radius) - .bg(cx.theme().danger_background) - .text_color(cx.theme().danger_foreground) - .child( - div() - .flex_1() - .w_full() - .text_center() - .child(SharedString::from("Messaging Relays not found")), - ), - ) - }) - .when(report.device_not_found, |this| { - this.child( - h_flex() - .flex_wrap() - .justify_center() - .p_2() - .h_20() - .w_full() - .text_sm() - .rounded(cx.theme().radius) - .bg(cx.theme().danger_background) - .text_color(cx.theme().danger_foreground) - .child( - div() - .flex_1() - .w_full() - .text_center() - .child(SharedString::from("Encryption Key not found")), - ), - ) - }) .when_some(report.error.clone(), |this, error| { this.child( h_flex() @@ -866,7 +882,7 @@ impl ChatPanel { .child(div().flex_1().w_full().text_center().child(error)), ) }) - .when_some(report.status.clone(), |this, output| { + .when_some(report.output.clone(), |this, output| { this.child( v_flex() .gap_2() @@ -993,9 +1009,9 @@ impl ChatPanel { .icon(IconName::Ellipsis) .small() .ghost() - .popup_menu({ + .dropdown_menu({ let id = id.to_owned(); - move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id))) + move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id))) }), ) .group_hover("", |this| this.visible()) @@ -1116,6 +1132,25 @@ impl ChatPanel { items } + + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { + match command { + Command::Insert(content) => { + self.send_message(content, window, cx); + } + Command::ChangeSubject(subject) => { + if self + .room + .update(cx, |this, cx| { + this.set_subject(*subject, cx); + }) + .is_err() + { + window.push_notification(Notification::error("Failed to change subject"), cx); + } + } + } + } } impl Panel for ChatPanel { @@ -1150,61 +1185,86 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .image_cache(self.image_cache.clone()) + .on_action(cx.listener(Self::on_command)) .size_full() .child( - list( - self.list_state.clone(), - cx.processor(|this, ix, window, cx| { - // Get and render message by index - this.render_message(ix, window, cx) - }), - ) - .flex_1(), + div() + .flex_1() + .size_full() + .child( + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + this.render_message(ix, window, cx) + }), + ) + .size_full(), + ) + .child(Scrollbar::vertical(&self.list_state)), ) .child( - div() + v_flex() .flex_shrink_0() + .p_2() .w_full() - .relative() - .px_3() - .py_2() + .gap_1p5() + .children(self.render_attachment_list(window, cx)) + .children(self.render_reply_list(window, cx)) .child( - v_flex() - .gap_1p5() - .children(self.render_attachment_list(window, cx)) - .children(self.render_reply_list(window, cx)) + h_flex() + .items_end() .child( - div() - .w_full() - .flex() - .items_end() - .gap_2p5() + Button::new("upload") + .icon(IconName::Plus) + .tooltip("Upload media") + .loading(self.uploading) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.upload(window, cx); + })), + ) + .child( + TextInput::new(&self.input) + .appearance(false) + .flex_1() + .text_sm(), + ) + .child( + h_flex() + .pl_1() + .gap_1() .child( - h_flex() - .gap_1() - .text_color(cx.theme().text_muted) - .child( - Button::new("upload") - .icon(IconName::Upload) - .loading(self.uploading) - .disabled(self.uploading) - .ghost() - .large() - .on_click(cx.listener( - move |this, _, window, cx| { - this.upload(window, cx); - }, - )), - ) - .child( - EmojiPicker::new() - .target(self.input.downgrade()) - .icon(IconName::Emoji) - .large(), + Button::new("emoji") + .icon(IconName::Emoji) + .ghost() + .large() + .dropdown_menu_with_anchor( + gpui::Corner::BottomLeft, + move |this, _window, _cx| { + this.horizontal() + .menu("👍", Box::new(Command::Insert("👍"))) + .menu("👎", Box::new(Command::Insert("👎"))) + .menu("😄", Box::new(Command::Insert("😄"))) + .menu("🎉", Box::new(Command::Insert("🎉"))) + .menu("😕", Box::new(Command::Insert("😕"))) + .menu("❤️", Box::new(Command::Insert("❤️"))) + .menu("🚀", Box::new(Command::Insert("🚀"))) + .menu("👀", Box::new(Command::Insert("👀"))) + }, ), ) - .child(TextInput::new(&self.input)), + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.send_text_message(window, cx); + })), + ), ), ), ) diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 94a62ac..cbff6f1 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -60,4 +60,4 @@ futures.workspace = true oneshot.workspace = true indexset = "0.12.3" -tracing-subscriber = { version = "0.3.18", features = ["fmt"] } +tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] } diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 0336fbd..14f69fa 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -78,26 +78,26 @@ fn main() { // Initialize theme registry theme::init(cx); - // Initialize the nostr client - state::init(cx); - - // Initialize device signer - // - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(cx); - // Initialize settings settings::init(cx); + // Initialize the nostr client + state::init(window, cx); + // Initialize relay auth registry relay_auth::init(window, cx); - // Initialize app registry - chat::init(cx); - // Initialize person registry person::init(cx); + // Initialize device signer + // + // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + device::init(window, cx); + + // Initialize app registry + chat::init(window, cx); + // Initialize auto update auto_update::init(cx); diff --git a/crates/coop/src/sidebar/entry.rs b/crates/coop/src/sidebar/entry.rs index e8c25d9..1f5bd35 100644 --- a/crates/coop/src/sidebar/entry.rs +++ b/crates/coop/src/sidebar/entry.rs @@ -1,7 +1,6 @@ use std::rc::Rc; use chat::RoomKind; -use chat_ui::{CopyPublicKey, OpenPublicKey}; use dock::ClosePanel; use gpui::prelude::FluentBuilder; use gpui::{ @@ -12,7 +11,6 @@ use nostr_sdk::prelude::*; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; -use ui::context_menu::ContextMenuExt; use ui::modal::ModalButtonProps; use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; @@ -153,12 +151,6 @@ impl RenderOnce for RoomEntry { ), ) .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .when_some(public_key, |this, public_key| { - this.context_menu(move |this, _window, _cx| { - this.menu("View Profile", Box::new(OpenPublicKey(public_key))) - .menu("Copy Public Key", Box::new(CopyPublicKey(public_key))) - }) - }) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { handler(event, window, cx); diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index 5050582..b7b7d6a 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -11,7 +11,7 @@ use gpui::prelude::FluentBuilder; use gpui::{ div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, - Task, Window, + Task, UniformListScrollHandle, Window, }; use nostr_sdk::prelude::*; use person::PersonRegistry; @@ -23,6 +23,7 @@ use ui::divider::Divider; use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; use ui::notification::Notification; +use ui::scroll::Scrollbar; use ui::{ h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, }; @@ -39,6 +40,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Sidebar { name: SharedString, focus_handle: FocusHandle, + scroll_handle: UniformListScrollHandle, /// Image cache image_cache: Entity, @@ -143,6 +145,7 @@ impl Sidebar { Self { name: "Sidebar".into(), focus_handle: cx.focus_handle(), + scroll_handle: UniformListScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), find_input, find_debouncer: DebouncedDelay::new(), @@ -206,17 +209,6 @@ impl Sidebar { /// Search fn search(&mut self, window: &mut Window, cx: &mut Context) { - // Return if a search is already in progress - if self.finding { - if self.find_task.is_none() { - window.push_notification("There is another search in progress", cx); - return; - } else { - // Cancel the ongoing search request - self.find_task = None; - } - } - // Get query let query = self.find_input.read(cx).value(); @@ -228,12 +220,14 @@ impl Sidebar { // Block the input until the search completes self.set_finding(true, window, cx); + // Create the search task let nostr = NostrRegistry::global(cx); let find_users = nostr.read(cx).search(&query, cx); // Run task in the main thread self.find_task = Some(cx.spawn_in(window, async move |this, cx| { let rooms = find_users.await?; + // Update the UI with the search results this.update_in(cx, |this, window, cx| { this.set_results(rooms, cx); @@ -699,9 +693,11 @@ impl Render for Sidebar { this.render_list_items(range, cx) }), ) + .track_scroll(&self.scroll_handle) .flex_1() .h_full(), ) + .child(Scrollbar::vertical(&self.scroll_handle)) }), ) .when(!self.selected_pkeys.read(cx).is_empty(), |this| { diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index e2ee5e3..32057f4 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -16,7 +16,7 @@ use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; use titlebar::TitleBar; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::popup_menu::PopupMenuExt; +use ui::menu::DropdownMenu; use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; use crate::panels::greeter; @@ -184,7 +184,7 @@ impl Workspace { .caret() .compact() .transparent() - .popup_menu(move |this, _window, _cx| { + .dropdown_menu(move |this, _window, _cx| { this.label(profile.name()) .separator() .menu("Profile", Box::new(ClosePanel)) diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 199ff94..f9b21e3 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -1,12 +1,11 @@ use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT, USER_GIFTWRAP}; +use state::{app_name, NostrRegistry, RelayState, DEVICE_GIFTWRAP, TIMEOUT}; mod device; @@ -14,8 +13,8 @@ pub use device::*; const IDENTIFIER: &str = "coop:device"; -pub fn init(cx: &mut App) { - DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx); +pub fn init(window: &mut Window, cx: &mut App) { + DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); } struct GlobalDeviceRegistry(Entity); @@ -27,11 +26,8 @@ impl Global for GlobalDeviceRegistry {} /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md #[derive(Debug)] pub struct DeviceRegistry { - /// Device signer - pub device_signer: Entity>>, - /// Device state - pub state: DeviceState, + state: DeviceState, /// Device requests requests: Entity>, @@ -40,7 +36,7 @@ pub struct DeviceRegistry { tasks: Vec>>, /// Subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, } impl DeviceRegistry { @@ -55,20 +51,14 @@ impl DeviceRegistry { } /// Create a new device registry instance - fn new(cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); let nip65_state = nostr.read(cx).nip65_state(); - let nip17_state = nostr.read(cx).nip17_state(); - let device_signer = cx.new(|_| None); + // Construct an entity for encryption signer requests let requests = cx.new(|_| HashSet::default()); - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(100); - let mut subscriptions = smallvec![]; - let mut tasks = vec![]; subscriptions.push( // Observe the NIP-65 state @@ -77,7 +67,7 @@ impl DeviceRegistry { RelayState::Idle => { this.reset(cx); } - RelayState::Configured => { + RelayState::Configured(_) => { this.get_announcement(cx); } _ => {} @@ -85,21 +75,57 @@ impl DeviceRegistry { }), ); - subscriptions.push( - // Observe the NIP-17 state - cx.observe(&nip17_state, |this, state, cx| { - if state.read(cx) == &RelayState::Configured { - this.get_messages(cx); - }; - }), - ); + cx.defer_in(window, |this, _window, cx| { + this.handle_notifications(cx); + }); - tasks.push( - // Handle nostr notifications - cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }), - ); + Self { + state: DeviceState::default(), + requests, + tasks: vec![], + _subscriptions: subscriptions, + } + } - tasks.push( + fn handle_notifications(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let (tx, rx) = flume::bounded::(100); + + cx.background_spawn(async move { + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); + + while let Some(notification) = notifications.next().await { + if let ClientNotification::Message { + message: RelayMessage::Event { event, .. }, + .. + } = notification + { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + match event.kind { + Kind::Custom(4454) => { + if verify_author(&client, event.as_ref()).await { + tx.send_async(event.into_owned()).await.ok(); + } + } + Kind::Custom(4455) => { + if verify_author(&client, event.as_ref()).await { + tx.send_async(event.into_owned()).await.ok(); + } + } + _ => {} + } + } + } + }) + .detach(); + + self.tasks.push( // Update GPUI states cx.spawn(async move |this, cx| { while let Ok(event) = rx.recv_async().await { @@ -121,137 +147,73 @@ impl DeviceRegistry { Ok(()) }), ); - - Self { - device_signer, - requests, - state: DeviceState::default(), - tasks, - _subscriptions: subscriptions, - } } - /// Handle nostr notifications - async fn handle_notifications(client: &Client, tx: &flume::Sender) -> Result<(), Error> { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Some(notification) = notifications.next().await { - if let ClientNotification::Message { - message: RelayMessage::Event { event, .. }, - .. - } = notification - { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - match event.kind { - Kind::Custom(4454) => { - if Self::verify_author(client, event.as_ref()).await { - tx.send_async(event.into_owned()).await.ok(); - } - } - Kind::Custom(4455) => { - if Self::verify_author(client, event.as_ref()).await { - tx.send_async(event.into_owned()).await.ok(); - } - } - _ => {} - } - } - } - - Ok(()) - } - - /// Verify the author of an event - async fn verify_author(client: &Client, event: &Event) -> bool { - if let Some(signer) = client.signer() { - if let Ok(public_key) = signer.get_public_key().await { - return public_key == event.pubkey; - } - } - false - } - - /// Encrypt and store device keys in the local database. - async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> { - let signer = client.signer().context("Signer not found")?; - let public_key = signer.get_public_key().await?; - - // Encrypt the value - let content = signer.nip44_encrypt(&public_key, secret).await?; - - // Construct the application data event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(IDENTIFIER)) - .build(public_key) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - client.database().save_event(&event).await?; - - Ok(()) - } - - /// Get device keys from the local database. - async fn get_keys(client: &Client) -> Result { - let signer = client.signer().context("Signer not found")?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(IDENTIFIER) - .author(public_key) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first() { - let content = signer.nip44_decrypt(&public_key, &event.content).await?; - let secret = SecretKey::parse(&content)?; - let keys = Keys::new(secret); - - Ok(keys) - } else { - Err(anyhow!("Key not found")) - } + pub fn state(&self) -> &DeviceState { + &self.state } /// Reset the device state pub fn reset(&mut self, cx: &mut Context) { + self.state = DeviceState::Initial; self.requests.update(cx, |this, cx| { this.clear(); cx.notify(); }); - - self.device_signer.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - - self.state = DeviceState::Initial; cx.notify(); } - /// Returns the device signer entity - pub fn signer(&self, cx: &App) -> Option> { - self.device_signer.read(cx).clone() - } - /// Set the decoupled encryption key for the current user - fn set_device_signer(&mut self, signer: S, cx: &mut Context) + fn set_signer(&mut self, new: S, cx: &mut Context) where S: NostrSigner + 'static, { - self.set_state(DeviceState::Set, cx); - self.device_signer.update(cx, |this, cx| { - *this = Some(Arc::new(signer)); - cx.notify(); + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + + self.tasks.push(cx.spawn(async move |this, cx| { + signer.set_encryption_signer(new).await; + + // Update state + this.update(cx, |this, cx| { + this.set_state(DeviceState::Set, cx); + this.get_messages(cx); + })?; + + Ok(()) + })); + } + + /// Continuously get gift wrap events for the current encryption keys + fn get_messages(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let messaging_relays = nostr.read(cx).messaging_relays(cx); + + let task: Task> = cx.background_spawn(async move { + let encryption_signer = signer + .get_encryption_signer() + .await + .context("Signer not found")?; + + let public_key = encryption_signer.get_public_key().await?; + let urls = messaging_relays.await; + + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(DEVICE_GIFTWRAP); + + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = + urls.iter().map(|relay| (relay, filter.clone())).collect(); + + client.subscribe(target).with_id(id).await?; + log::info!("Subscribed to encryption gift-wrap messages"); + + Ok(()) }); - log::info!("Device Signer set"); + task.detach(); } /// Set the device state @@ -268,51 +230,6 @@ impl DeviceRegistry { }); } - /// Continuously get gift wrap events for the current user in their messaging relays - fn get_messages(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let device_signer = self.device_signer.read(cx).clone(); - let messaging_relays = nostr.read(cx).messaging_relays(cx); - - let task: Task> = cx.background_spawn(async move { - let urls = messaging_relays.await; - let user_signer = client.signer().context("Signer not found")?; - let public_key = user_signer.get_public_key().await?; - - // Get messages with dekey - if let Some(signer) = device_signer.as_ref() { - let device_pkey = signer.get_public_key().await?; - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(device_pkey); - let id = SubscriptionId::new(DEVICE_GIFTWRAP); - - // Construct target for subscription - let target = urls - .iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect::>(); - - client.subscribe(target).with_id(id).await?; - } - - // Get messages with user key - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - let id = SubscriptionId::new(USER_GIFTWRAP); - - // Construct target for subscription - let target = urls - .iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect::>(); - - client.subscribe(target).with_id(id).await?; - - Ok(()) - }); - - task.detach_and_log_err(cx); - } - /// Get device announcement for current user fn get_announcement(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -388,7 +305,7 @@ impl DeviceRegistry { client.send_event(&event).to_nip65().await?; // Save device keys to the database - Self::set_keys(&client, &secret).await?; + set_keys(&client, &secret).await?; Ok(()) }); @@ -396,7 +313,7 @@ impl DeviceRegistry { cx.spawn(async move |this, cx| { if task.await.is_ok() { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); this.listen_device_request(cx); }) .ok(); @@ -414,7 +331,7 @@ impl DeviceRegistry { let device_pubkey = announcement.public_key(); let task: Task> = cx.background_spawn(async move { - if let Ok(keys) = Self::get_keys(&client).await { + if let Ok(keys) = get_keys(&client).await { if keys.public_key() != device_pubkey { return Err(anyhow!("Key mismatch")); }; @@ -429,7 +346,7 @@ impl DeviceRegistry { match task.await { Ok(keys) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); this.listen_device_request(cx); }) .ok(); @@ -551,7 +468,7 @@ impl DeviceRegistry { match task.await { Ok(Some(keys)) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); }) .ok(); } @@ -595,7 +512,7 @@ impl DeviceRegistry { match task.await { Ok(keys) => { this.update(cx, |this, cx| { - this.set_device_signer(keys, cx); + this.set_signer(keys, cx); }) .ok(); } @@ -617,7 +534,7 @@ impl DeviceRegistry { let signer = client.signer().context("Signer not found")?; // Get device keys - let keys = Self::get_keys(&client).await?; + let keys = get_keys(&client).await?; let secret = keys.secret_key().to_secret_hex(); // Extract the target public key from the event tags @@ -650,3 +567,56 @@ impl DeviceRegistry { task.detach(); } } + +/// Verify the author of an event +async fn verify_author(client: &Client, event: &Event) -> bool { + if let Some(signer) = client.signer() { + if let Ok(public_key) = signer.get_public_key().await { + return public_key == event.pubkey; + } + } + false +} + +/// Encrypt and store device keys in the local database. +async fn set_keys(client: &Client, secret: &str) -> Result<(), Error> { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + // Encrypt the value + let content = signer.nip44_encrypt(&public_key, secret).await?; + + // Construct the application data event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tag(Tag::identifier(IDENTIFIER)) + .build(public_key) + .sign(&Keys::generate()) + .await?; + + // Save the event to the database + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Get device keys from the local database. +async fn get_keys(client: &Client) -> Result { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(IDENTIFIER) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first() { + let content = signer.nip44_decrypt(&public_key, &event.content).await?; + let secret = SecretKey::parse(&content)?; + let keys = Keys::new(secret); + + Ok(keys) + } else { + Err(anyhow!("Key not found")) + } +} diff --git a/crates/dock/src/panel.rs b/crates/dock/src/panel.rs index 00bec24..1e5c8d3 100644 --- a/crates/dock/src/panel.rs +++ b/crates/dock/src/panel.rs @@ -3,7 +3,7 @@ use gpui::{ SharedString, Window, }; use ui::button::Button; -use ui::popup_menu::PopupMenu; +use ui::menu::PopupMenu; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PanelEvent { diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 94d829c..88c3ead 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -9,7 +9,7 @@ use gpui::{ }; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants as _}; -use ui::popup_menu::{PopupMenu, PopupMenuExt}; +use ui::menu::{DropdownMenu, PopupMenu}; use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; use crate::dock::DockPlacement; @@ -454,7 +454,7 @@ impl TabPanel { .small() .ghost() .rounded() - .popup_menu({ + .dropdown_menu({ let zoomable = state.zoomable; let closable = state.closable; diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 624efe8..a0426bc 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -26,6 +26,7 @@ impl Global for GlobalPersonRegistry {} enum Dispatch { Person(Box), Announcement(Box), + Relays(Box), } /// Person Registry @@ -100,6 +101,9 @@ impl PersonRegistry { Dispatch::Announcement(event) => { this.set_announcement(&event, cx); } + Dispatch::Relays(event) => { + this.set_messaging_relays(&event, cx); + } }; }) .ok(); @@ -140,6 +144,7 @@ impl PersonRegistry { /// Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); + let mut processed: HashSet = HashSet::new(); while let Some(notification) = notifications.next().await { let ClientNotification::Message { message, .. } = notification else { @@ -148,6 +153,11 @@ impl PersonRegistry { }; if let RelayMessage::Event { event, .. } = message { + // Skip if the event has already been processed + if !processed.insert(event.id) { + continue; + } + match event.kind { Kind::Metadata => { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); @@ -157,18 +167,24 @@ impl PersonRegistry { // Send tx.send_async(Dispatch::Person(val)).await.ok(); } - Kind::Custom(10044) => { - let val = Box::new(event.into_owned()); - - // Send - tx.send_async(Dispatch::Announcement(val)).await.ok(); - } Kind::ContactList => { let public_keys = event.extract_public_keys(); // Get metadata for all public keys Self::get_metadata(client, public_keys).await.ok(); } + Kind::InboxRelays => { + let val = Box::new(event.into_owned()); + + // Send + tx.send_async(Dispatch::Relays(val)).await.ok(); + } + Kind::Custom(10044) => { + let val = Box::new(event.into_owned()); + + // Send + tx.send_async(Dispatch::Announcement(val)).await.ok(); + } _ => {} } } @@ -264,6 +280,18 @@ impl PersonRegistry { } } + /// Set messaging relays for a person + fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) { + if let Some(person) = self.persons.get(&event.pubkey) { + let urls: Vec = nip17::extract_relay_list(event).cloned().collect(); + + person.update(cx, |person, cx| { + person.set_messaging_relays(event.pubkey, urls); + cx.notify(); + }); + } + } + /// Insert batch of persons fn bulk_inserts(&mut self, persons: Vec, cx: &mut Context) { for person in persons.into_iter() { diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f37fffd..3c515fd 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -18,6 +18,9 @@ pub struct Person { /// Dekey (NIP-4e) announcement announcement: Option, + + /// Messaging relays + messaging_relays: Vec, } impl PartialEq for Person { @@ -58,6 +61,7 @@ impl Person { public_key, metadata, announcement: None, + messaging_relays: vec![], } } @@ -82,6 +86,25 @@ impl Person { log::info!("Updated announcement for: {}", self.public_key()); } + /// Get profile messaging relays + pub fn messaging_relays(&self) -> &Vec { + &self.messaging_relays + } + + /// Get relay hint for messaging relay list + pub fn messaging_relay_hint(&self) -> Option { + self.messaging_relays.first().cloned() + } + + /// Set profile messaging relays + pub fn set_messaging_relays(&mut self, public_key: PublicKey, relays: I) + where + I: IntoIterator, + { + self.messaging_relays = relays.into_iter().collect(); + log::info!("Updated messaging relays for: {}", public_key); + } + /// Get profile avatar pub fn avatar(&self) -> SharedString { self.metadata() diff --git a/crates/relay_auth/src/lib.rs b/crates/relay_auth/src/lib.rs index 8f26d4d..c19c847 100644 --- a/crates/relay_auth/src/lib.rs +++ b/crates/relay_auth/src/lib.rs @@ -1,19 +1,19 @@ use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; -use std::hash::{Hash, Hasher}; +use std::hash::Hash; use std::rc::Rc; use std::sync::Arc; use anyhow::{anyhow, Context as AnyhowContext, Error}; use gpui::{ App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, - Subscription, Task, Window, + Task, Window, }; use nostr_sdk::prelude::*; use settings::{AppSettings, AuthMode}; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrRegistry}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; @@ -27,18 +27,12 @@ pub fn init(window: &mut Window, cx: &mut App) { } /// Authentication request -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct AuthRequest { +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +struct AuthRequest { url: RelayUrl, challenge: String, } -impl Hash for AuthRequest { - fn hash(&self, state: &mut H) { - self.challenge.hash(state); - } -} - impl AuthRequest { pub fn new(challenge: impl Into, url: RelayUrl) -> Self { Self { @@ -56,6 +50,12 @@ impl AuthRequest { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Signal { + Auth(Arc), + Pending((EventId, RelayUrl)), +} + struct GlobalRelayAuth(Entity); impl Global for GlobalRelayAuth {} @@ -63,11 +63,11 @@ impl Global for GlobalRelayAuth {} // Relay authentication #[derive(Debug)] pub struct RelayAuth { + /// Pending events waiting for resend after authentication + pending_events: HashSet<(EventId, RelayUrl)>, + /// Tasks for asynchronous operations tasks: SmallVec<[Task<()>; 2]>, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, } impl RelayAuth { @@ -83,90 +83,104 @@ impl RelayAuth { /// Create a new relay auth instance fn new(window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::>(100); - - let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; - - subscriptions.push( - // Observe the current state - cx.observe(&nostr, move |this, state, cx| { - if state.read(cx).connected() { - this.handle_notifications(tx.clone(), cx) - } - }), - ); - - tasks.push( - // Update GPUI states - cx.spawn_in(window, async move |this, cx| { - while let Ok(req) = rx.recv_async().await { - this.update_in(cx, |this, window, cx| { - this.handle_auth(&req, window, cx); - }) - .ok(); - } - }), - ); + cx.defer_in(window, |this, window, cx| { + this.handle_notifications(window, cx); + }); Self { - tasks, - _subscriptions: subscriptions, + pending_events: HashSet::default(), + tasks: smallvec![], } } - // Handle nostr notifications - fn handle_notifications( - &mut self, - tx: flume::Sender>, - cx: &mut Context, - ) { + /// Handle nostr notifications + fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let task = cx.background_spawn(async move { + // Channel for communication between nostr and gpui + let (tx, rx) = flume::bounded::(256); + + self.tasks.push(cx.background_spawn(async move { + log::info!("Started handling nostr notifications"); let mut notifications = client.notifications(); let mut challenges: HashSet> = HashSet::default(); while let Some(notification) = notifications.next().await { - match notification { - ClientNotification::Message { relay_url, message } => { - match message { - RelayMessage::Auth { challenge } => { - if challenges.insert(challenge.clone()) { - let request = AuthRequest::new(challenge, relay_url); - tx.send_async(Arc::new(request)).await.ok(); - } - } - RelayMessage::Ok { - event_id, message, .. - } => { - let msg = MachineReadablePrefix::parse(&message); - let mut tracker = tracker().write().await; + if let ClientNotification::Message { relay_url, message } = notification { + match message { + RelayMessage::Auth { challenge } => { + if challenges.insert(challenge.clone()) { + let request = Arc::new(AuthRequest::new(challenge, relay_url)); + let signal = Signal::Auth(request); - // Handle authentication messages - if let Some(MachineReadablePrefix::AuthRequired) = msg { - // Keep track of events that need to be resent after authentication - tracker.add_to_pending(event_id, relay_url); - } else { - // Keep track of events sent by Coop - tracker.sent(event_id) - } + tx.send_async(signal).await.ok(); } - _ => {} } + RelayMessage::Ok { + event_id, message, .. + } => { + let msg = MachineReadablePrefix::parse(&message); + + // Handle authentication messages + if let Some(MachineReadablePrefix::AuthRequired) = msg { + let signal = Signal::Pending((event_id, relay_url)); + tx.send_async(signal).await.ok(); + } + } + _ => {} } - ClientNotification::Shutdown => break, - _ => {} } } - }); + })); - self.tasks.push(task); + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + while let Ok(signal) = rx.recv_async().await { + match signal { + Signal::Auth(req) => { + this.update_in(cx, |this, window, cx| { + this.handle_auth(&req, window, cx); + }) + .ok(); + } + Signal::Pending((event_id, relay_url)) => { + this.update_in(cx, |this, _window, cx| { + this.insert_pending_event(event_id, relay_url, cx); + }) + .ok(); + } + } + } + })); } + /// Insert a pending event waiting for resend after authentication + fn insert_pending_event(&mut self, id: EventId, relay: RelayUrl, cx: &mut Context) { + self.pending_events.insert((id, relay)); + cx.notify(); + } + + /// Get all pending events for a specific relay, + fn get_pending_events(&self, relay: &RelayUrl, _cx: &App) -> Vec { + let pending_events: Vec = self + .pending_events + .iter() + .filter(|(_, pending_relay)| pending_relay == relay) + .map(|(id, _relay)| id) + .cloned() + .collect(); + + pending_events + } + + /// Clear all pending events for a specific relay, + fn clear_pending_events(&mut self, relay: &RelayUrl, cx: &mut Context) { + self.pending_events + .retain(|(_, pending_relay)| pending_relay != relay); + cx.notify(); + } + + /// Handle authentication request fn handle_auth(&mut self, req: &Arc, window: &mut Window, cx: &mut Context) { let settings = AppSettings::global(cx); let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx); @@ -181,29 +195,25 @@ impl RelayAuth { } } - /// Respond to an authentication request. - fn response(&self, req: &Arc, window: &Window, cx: &Context) { - let settings = AppSettings::global(cx); + /// Send auth response and wait for confirmation + fn auth(&self, req: &Arc, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let req = req.clone(); - let challenge = req.challenge().to_string(); - let async_req = req.clone(); - let task: Task> = cx.background_spawn(async move { + // Get all pending events for the relay + let pending_events = self.get_pending_events(req.url(), cx); + + cx.background_spawn(async move { // Construct event - let builder = EventBuilder::auth(async_req.challenge(), async_req.url().clone()); + let builder = EventBuilder::auth(req.challenge(), req.url().clone()); let event = client.sign_event_builder(builder).await?; // Get the event ID let id = event.id; // Get the relay - let relay = client - .relay(async_req.url()) - .await? - .context("Relay not found")?; + let relay = client.relay(req.url()).await?.context("Relay not found")?; // Subscribe to notifications let mut notifications = relay.notifications(); @@ -213,28 +223,35 @@ impl RelayAuth { .send_msg(ClientMessage::Auth(Cow::Borrowed(&event))) .await?; + log::info!("Sending AUTH event"); + while let Some(notification) = notifications.next().await { match notification { RelayNotification::Message { message: RelayMessage::Ok { event_id, .. }, } => { - if id == event_id { - // Re-subscribe to previous subscription - // relay.resubscribe().await?; - - // Get all pending events that need to be resent - let mut tracker = tracker().write().await; - let ids: Vec = tracker.pending_resend(relay.url()); - - for id in ids.into_iter() { - if let Some(event) = client.database().event_by_id(&id).await? { - let event_id = relay.send_event(&event).await?; - tracker.sent(event_id); - } - } - - return Ok(()); + if id != event_id { + continue; } + + // Get all subscriptions + let subscriptions = relay.subscriptions().await; + + // Re-subscribe to previous subscriptions + for (id, filters) in subscriptions.into_iter() { + if !filters.is_empty() { + relay.send_msg(ClientMessage::req(id, filters)).await?; + } + } + + // Re-send pending events + for id in pending_events { + if let Some(event) = client.database().event_by_id(&id).await? { + relay.send_event(&event).await?; + } + } + + return Ok(()); } RelayNotification::AuthenticationFailed => break, _ => {} @@ -242,22 +259,33 @@ impl RelayAuth { } Err(anyhow!("Authentication failed")) - }); + }) + } + + /// Respond to an authentication request. + fn response(&self, req: &Arc, window: &Window, cx: &Context) { + let settings = AppSettings::global(cx); + let req = req.clone(); + let challenge = req.challenge().to_string(); + + // Create a task for authentication + let task = self.auth(&req, cx); cx.spawn_in(window, async move |this, cx| { let result = task.await; let url = req.url(); - this.update_in(cx, |_this, window, cx| { + this.update_in(cx, |this, window, cx| { window.clear_notification(challenge, cx); match result { Ok(_) => { + // Clear pending events for the authenticated relay + this.clear_pending_events(url, cx); // Save the authenticated relay to automatically authenticate future requests settings.update(cx, |this, cx| { this.add_trusted_relay(url, cx); }); - window.push_notification(format!("{} has been authenticated", url), cx); } Err(e) => { diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b1538e2..977d97b 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -52,10 +52,24 @@ pub enum AuthMode { /// Signer kind #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum SignerKind { - #[default] Auto, + #[default] User, - Device, + Encryption, +} + +impl SignerKind { + pub fn auto(&self) -> bool { + matches!(self, SignerKind::Auto) + } + + pub fn user(&self) -> bool { + matches!(self, SignerKind::User) + } + + pub fn encryption(&self) -> bool { + matches!(self, SignerKind::Encryption) + } } /// Room configuration @@ -65,6 +79,16 @@ pub struct RoomConfig { signer_kind: SignerKind, } +impl RoomConfig { + pub fn backup(&self) -> bool { + self.backup + } + + pub fn signer_kind(&self) -> &SignerKind { + &self.signer_kind + } +} + /// Settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs index 992a272..f1bc85d 100644 --- a/crates/state/src/constants.rs +++ b/crates/state/src/constants.rs @@ -39,6 +39,8 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; /// Default search relays pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"]; +pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"]; + /// Default bootstrap relays pub const BOOTSTRAP_RELAYS: [&str; 3] = [ "wss://relay.damus.io", diff --git a/crates/state/src/device.rs b/crates/state/src/device.rs deleted file mode 100644 index f809c38..0000000 --- a/crates/state/src/device.rs +++ /dev/null @@ -1,62 +0,0 @@ -use gpui::SharedString; -use nostr_sdk::prelude::*; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub enum DeviceState { - #[default] - Initial, - Requesting, - Set, -} - -/// Announcement -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Announcement { - /// The public key of the device that created this announcement. - public_key: PublicKey, - - /// The name of the device that created this announcement. - client_name: Option, -} - -impl From<&Event> for Announcement { - fn from(val: &Event) -> Self { - let public_key = val - .tags - .iter() - .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "P") - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .unwrap_or(val.pubkey); - - let client_name = val - .tags - .find(TagKind::Client) - .and_then(|tag| tag.content()) - .map(|c| c.to_string()); - - Self::new(public_key, client_name) - } -} - -impl Announcement { - pub fn new(public_key: PublicKey, client_name: Option) -> Self { - Self { - public_key, - client_name, - } - } - - /// Returns the public key of the device that created this announcement. - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - /// Returns the client name of the device that created this announcement. - pub fn client_name(&self) -> SharedString { - self.client_name - .as_ref() - .map(SharedString::from) - .unwrap_or(SharedString::from("Unknown")) - } -} diff --git a/crates/state/src/event.rs b/crates/state/src/event.rs deleted file mode 100644 index e7de936..0000000 --- a/crates/state/src/event.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::collections::HashSet; -use std::sync::{Arc, OnceLock}; - -use nostr_sdk::prelude::*; -use smol::lock::RwLock; - -static TRACKER: OnceLock>> = OnceLock::new(); - -pub fn tracker() -> &'static Arc> { - TRACKER.get_or_init(|| Arc::new(RwLock::new(EventTracker::default()))) -} - -/// Event tracker -#[derive(Debug, Clone, Default)] -pub struct EventTracker { - /// Tracking events sent by Coop in the current session - sent_ids: HashSet, - - /// Events that need to be resent later - pending_resend: HashSet<(EventId, RelayUrl)>, -} - -impl EventTracker { - /// Check if an event was sent by Coop in the current session. - pub fn is_sent_by_coop(&self, id: &EventId) -> bool { - self.sent_ids.contains(id) - } - - /// Mark an event as sent by Coop. - pub fn sent(&mut self, id: EventId) { - self.sent_ids.insert(id); - } - - /// Get all events that need to be resent later for a specific relay. - pub fn pending_resend(&mut self, relay: &RelayUrl) -> Vec { - self.pending_resend - .extract_if(|(_id, url)| url == relay) - .map(|(id, _url)| id) - .collect() - } - - /// Add an event (id and relay url) to the pending resend set. - pub fn add_to_pending(&mut self, id: EventId, url: RelayUrl) { - self.pending_resend.insert((id, url)); - } -} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 49d73ad..70873a3 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -5,23 +5,21 @@ use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}; use nostr_connect::prelude::*; use nostr_gossip_memory::prelude::*; use nostr_lmdb::NostrLmdb; use nostr_sdk::prelude::*; mod constants; -mod event; mod nip05; mod signer; pub use constants::*; -pub use event::*; pub use nip05::*; pub use signer::*; -pub fn init(cx: &mut App) { +pub fn init(window: &mut Window, cx: &mut App) { // rustls uses the `aws_lc_rs` provider by default // This only errors if the default provider has already // been installed. We can ignore this `Result`. @@ -32,10 +30,7 @@ pub fn init(cx: &mut App) { // Initialize the tokio runtime gpui_tokio::init(cx); - // Initialize the event tracker - let _tracker = tracker(); - - NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); + NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx); } struct GlobalNostrRegistry(Entity); @@ -87,7 +82,7 @@ impl NostrRegistry { } /// Create a new nostr instance - fn new(cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { // Construct the nostr lmdb instance let lmdb = cx.foreground_executor().block_on(async move { NostrLmdb::open(config_dir().join("nostr")) @@ -96,13 +91,9 @@ impl NostrRegistry { }); // Construct the nostr signer - let app_keys = Self::create_or_init_app_keys().unwrap_or(Keys::generate()); + let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate()); let signer = Arc::new(CoopSigner::new(app_keys.clone())); - // Construct the relay states entity - let nip65 = cx.new(|_| RelayState::default()); - let nip17 = cx.new(|_| RelayState::default()); - // Construct the nostr client let client = ClientBuilder::default() .signer(signer.clone()) @@ -122,25 +113,33 @@ impl NostrRegistry { }) .build(); + // Construct the relay states entity + let nip65 = cx.new(|_| RelayState::default()); + let nip17 = cx.new(|_| RelayState::default()); + let mut subscriptions = vec![]; subscriptions.push( // Observe the NIP-65 state cx.observe(&nip65, |this, state, cx| { - if state.read(cx).configured() { + if state.read(cx).configured().is_some() { this.get_profile(cx); this.get_messaging_relays(cx); } }), ); - cx.defer(|cx| { - let nostr = NostrRegistry::global(cx); + subscriptions.push( + // Observe the NIP-17 state + cx.observe(&nip17, |this, nip17, cx| { + if let Some(event) = nip17.read(cx).configured().cloned() { + this.subscribe_to_giftwrap_events(&event, cx); + }; + }), + ); - // Connect to the bootstrapping relays - nostr.update(cx, |this, cx| { - this.connect(cx); - }); + cx.defer_in(window, |this, _window, cx| { + this.connect(cx); }); Self { @@ -160,36 +159,35 @@ impl NostrRegistry { fn connect(&mut self, cx: &mut Context) { let client = self.client(); - let task: Task> = cx.background_spawn(async move { - // Add search relay to the relay pool - for url in SEARCH_RELAYS.into_iter() { - client.add_relay(url).and_connect().await?; - } - - // Add bootstrap relay to the relay pool - for url in BOOTSTRAP_RELAYS.into_iter() { - client.add_relay(url).and_connect().await?; - } - - Ok(()) - }); - self.tasks.push(cx.spawn(async move |this, cx| { - // Wait for the task to complete - task.await?; - - // Update the state - this.update(cx, |this, cx| { - this.set_connected(cx); - })?; - - // Small delay cx.background_executor() - .timer(Duration::from_millis(200)) + .await_on_background(async move { + // Add search relay to the relay pool + for url in INDEXER_RELAYS.into_iter() { + client + .add_relay(url) + .capabilities(RelayCapabilities::DISCOVERY) + .await + .ok(); + } + + // Add search relay to the relay pool + for url in SEARCH_RELAYS.into_iter() { + client.add_relay(url).await.ok(); + } + + // Add bootstrap relay to the relay pool + for url in BOOTSTRAP_RELAYS.into_iter() { + client.add_relay(url).await.ok(); + } + + client.connect().await; + }) .await; // Update the state this.update(cx, |this, cx| { + this.set_connected(cx); this.get_signer(cx); })?; @@ -244,30 +242,6 @@ impl NostrRegistry { cx.notify(); } - /// Get a relay hint (messaging relay) for a given public key - /// - /// Used for building chat messages - pub fn relay_hint(&self, public_key: &PublicKey, cx: &App) -> Task> { - let client = self.client(); - let public_key = public_key.to_owned(); - - cx.background_spawn(async move { - let filter = Filter::new() - .author(public_key) - .kind(Kind::InboxRelays) - .limit(1); - - if let Ok(events) = client.database().query(filter).await { - if let Some(event) = events.first_owned() { - let relays: Vec = nip17::extract_owned_relay_list(event).collect(); - return relays.first().cloned(); - } - } - - None - }) - } - /// Get a list of messaging relays with current signer's public key pub fn messaging_relays(&self, cx: &App) -> Task> { let client = self.client(); @@ -292,12 +266,11 @@ impl NostrRegistry { .await .ok() .and_then(|events| events.first_owned()) - .map(|event| nip17::extract_owned_relay_list(event).collect()) + .map(|event| nip17::extract_owned_relay_list(event).take(3).collect()) .unwrap_or_default(); for relay in relays.iter() { - client.add_relay(relay).await.ok(); - client.connect_relay(relay).await.ok(); + client.add_relay(relay).and_connect().await.ok(); } relays @@ -305,15 +278,36 @@ impl NostrRegistry { } /// Reset all relay states - pub fn reset_relay_states(&mut self, cx: &mut Context) { + pub fn reset_relays(&mut self, cx: &mut Context) { + let client = self.client(); + self.nip65.update(cx, |this, cx| { *this = RelayState::default(); cx.notify(); }); + self.nip17.update(cx, |this, cx| { *this = RelayState::default(); cx.notify(); }); + + self.tasks.push(cx.background_spawn(async move { + let relays = client.relays().await; + + for (relay_url, relay) in relays.iter() { + let url = relay_url.as_str(); + let default_relay = BOOTSTRAP_RELAYS.contains(&url) + || SEARCH_RELAYS.contains(&url) + || INDEXER_RELAYS.contains(&url); + + if !default_relay { + relay.unsubscribe_all().await?; + relay.disconnect(); + } + } + + Ok(()) + })); } /// Set the signer for the nostr client and verify the public key @@ -329,6 +323,9 @@ impl NostrRegistry { // Update signer signer.switch(new, owned).await; + // Unsubscribe from all subscriptions + client.unsubscribe_all().await?; + // Verify signer let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; @@ -343,89 +340,14 @@ impl NostrRegistry { // Update states this.update(cx, |this, cx| { - this.reset_relay_states(cx); + this.reset_relays(cx); + this.get_relay_list(cx); })?; Ok(()) })); } - /* - async fn get_adv_events_by(client: &Client, event: &Event) -> Result<(), Error> { - // Subscription options - let opts = SubscribeAutoCloseOptions::default() - .timeout(Some(Duration::from_secs(TIMEOUT))) - .exit_policy(ReqExitPolicy::ExitOnEOSE); - - // Extract write relays from event - let write_relays: Vec<&RelayUrl> = nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url) - } else { - None - } - }) - .collect(); - - // Ensure relay connections - for relay in write_relays.iter() { - client.add_relay(*relay).await?; - client.connect_relay(*relay).await?; - } - - // Construct filter for inbox relays - let inbox = Filter::new() - .kind(Kind::InboxRelays) - .author(event.pubkey) - .limit(1); - - // Construct filter for encryption announcement - let announcement = Filter::new() - .kind(Kind::Custom(10044)) - .author(event.pubkey) - .limit(1); - - // Construct target for subscription - let target = write_relays - .into_iter() - .map(|relay| (relay, vec![inbox.clone(), announcement.clone()])) - .collect::>(); - - client.subscribe(target).close_on(opts).await?; - - Ok(()) - } - */ - - /// Get or create a new app keys - fn create_or_init_app_keys() -> Result { - let dir = config_dir().join(".app_keys"); - let content = match std::fs::read(&dir) { - Ok(content) => content, - Err(_) => { - // Generate new keys if file doesn't exist - let keys = Keys::generate(); - let secret_key = keys.secret_key(); - - // Create directory and write secret key - std::fs::create_dir_all(dir.parent().unwrap())?; - std::fs::write(&dir, secret_key.to_secret_bytes())?; - - // Set permissions to readonly - let mut perms = std::fs::metadata(&dir)?.permissions(); - perms.set_mode(0o400); - std::fs::set_permissions(&dir, perms)?; - - return Ok(keys); - } - }; - let secret_key = SecretKey::from_slice(&content)?; - let keys = Keys::new(secret_key); - - Ok(keys) - } - // Get relay list for current user fn get_relay_list(&mut self, cx: &mut Context) { let client = self.client(); @@ -454,14 +376,13 @@ impl NostrRegistry { let mut stream = client .stream_events(target) + .policy(ReqExitPolicy::WaitForEvents(1)) .timeout(Duration::from_secs(TIMEOUT)) .await?; while let Some((_url, res)) = stream.next().await { match res { Ok(event) => { - log::info!("Received relay list event: {event:?}"); - // Construct a filter to continuously receive relay list events let filter = Filter::new() .kind(Kind::RelayList) @@ -477,7 +398,7 @@ impl NostrRegistry { // Subscribe to the relay list events client.subscribe(target).await?; - return Ok(RelayState::Configured); + return Ok(RelayState::Configured(Box::new(event))); } Err(e) => { log::error!("Failed to receive relay list event: {e}"); @@ -531,14 +452,13 @@ impl NostrRegistry { // Stream events from the write relays let mut stream = client .stream_events(filter) + .policy(ReqExitPolicy::WaitForEvents(1)) .timeout(Duration::from_secs(TIMEOUT)) .await?; while let Some((_url, res)) = stream.next().await { match res { Ok(event) => { - log::info!("Received messaging relays event: {event:?}"); - // Construct a filter to continuously receive relay list events let filter = Filter::new() .kind(Kind::InboxRelays) @@ -548,7 +468,7 @@ impl NostrRegistry { // Subscribe to the relay list events client.subscribe(filter).await?; - return Ok(RelayState::Configured); + return Ok(RelayState::Configured(Box::new(event))); } Err(e) => { log::error!("Failed to get messaging relays: {e}"); @@ -578,6 +498,41 @@ impl NostrRegistry { })); } + /// Continuously get gift wrap events for the current user in their messaging relays + fn subscribe_to_giftwrap_events(&mut self, relay_list: &Event, cx: &mut Context) { + let client = self.client(); + let signer = self.signer(); + let relay_urls: Vec = nip17::extract_relay_list(relay_list).cloned().collect(); + + let task: Task> = cx.background_spawn(async move { + let public_key = signer.get_public_key().await?; + + for url in relay_urls.iter() { + client.add_relay(url).and_connect().await?; + } + + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(USER_GIFTWRAP); + + // Construct target for subscription + let target: HashMap<&RelayUrl, Filter> = relay_urls + .iter() + .map(|relay| (relay, filter.clone())) + .collect(); + + let output = client.subscribe(target).with_id(id).await?; + + log::info!( + "Successfully subscribed to user gift-wrap messages on: {:?}", + output.success + ); + + Ok(()) + }); + + task.detach(); + } + /// Get profile and contact list for current user fn get_profile(&mut self, cx: &mut Context) { let client = self.client(); @@ -648,6 +603,7 @@ impl NostrRegistry { fn set_default_signer(&mut self, cx: &mut Context) { let client = self.client(); let keys = Keys::generate(); + let async_keys = keys.clone(); // Create a write credential task let write_credential = cx.write_credentials( @@ -656,21 +612,18 @@ impl NostrRegistry { &keys.secret_key().to_secret_bytes(), ); - // Update the signer - self.set_signer(keys, false, cx); - // Set the creating signer status self.set_creating_signer(true, cx); // Run async tasks in background let task: Task> = cx.background_spawn(async move { - let signer = client.signer().context("Signer not found")?; + let signer = async_keys.into_nostr_signer(); // Get default relay list let relay_list = default_relay_list(); // Publish relay list event - let event = EventBuilder::relay_list(relay_list).sign(signer).await?; + let event = EventBuilder::relay_list(relay_list).sign(&signer).await?; client .send_event(&event) .broadcast() @@ -683,33 +636,36 @@ impl NostrRegistry { let metadata = Metadata::new().display_name(&name).picture(avatar); // Publish metadata event - let event = EventBuilder::metadata(&metadata).sign(signer).await?; + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; client .send_event(&event) .broadcast() .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) .await?; // Construct the default contact list let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())]; // Publish contact list event - let event = EventBuilder::contact_list(contacts).sign(signer).await?; + let event = EventBuilder::contact_list(contacts).sign(&signer).await?; client .send_event(&event) .broadcast() .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) .await?; // Construct the default messaging relay list let relays = default_messaging_relays(); // Publish messaging relay list event - let event = EventBuilder::nip17_relay_list(relays).sign(signer).await?; + let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; client .send_event(&event) .to_nip65() .ok_timeout(Duration::from_secs(TIMEOUT)) + .ack_policy(AckPolicy::none()) .await?; // Write user's credentials to the system keyring @@ -724,7 +680,7 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.set_creating_signer(false, cx); - this.get_relay_list(cx); + this.set_signer(keys, false, cx); })?; Ok(()) @@ -743,7 +699,6 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.set_signer(keys, false, cx); - this.get_relay_list(cx); })?; } _ => { @@ -786,7 +741,6 @@ impl NostrRegistry { Ok(signer) => { this.update(cx, |this, cx| { this.set_signer(signer, true, cx); - this.get_relay_list(cx); }) .ok(); } @@ -882,9 +836,30 @@ impl NostrRegistry { let client = self.client(); let query = query.to_string(); + // Get the address task if the query is a valid NIP-05 address + let address_task = if let Ok(addr) = Nip05Address::parse(&query) { + Some(self.get_address(addr, cx)) + } else { + None + }; + cx.background_spawn(async move { let mut results: Vec = Vec::with_capacity(FIND_LIMIT); + // Return early if the query is a valid NIP-05 address + if let Some(task) = address_task { + if let Ok(public_key) = task.await { + results.push(public_key); + return Ok(results); + } + } + + // Return early if the query is a valid public key + if let Ok(public_key) = PublicKey::parse(&query) { + results.push(public_key); + return Ok(results); + } + // Construct the filter for the search query let filter = Filter::new() .search(query.to_lowercase()) @@ -980,6 +955,36 @@ impl NostrRegistry { } } +/// Get or create a new app keys +fn get_or_init_app_keys() -> Result { + let dir = config_dir().join(".app_keys"); + + let content = match std::fs::read(&dir) { + Ok(content) => content, + Err(_) => { + // Generate new keys if file doesn't exist + let keys = Keys::generate(); + let secret_key = keys.secret_key(); + + // Create directory and write secret key + std::fs::create_dir_all(dir.parent().unwrap())?; + std::fs::write(&dir, secret_key.to_secret_bytes())?; + + // Set permissions to readonly + let mut perms = std::fs::metadata(&dir)?.permissions(); + perms.set_mode(0o400); + std::fs::set_permissions(&dir, perms)?; + + return Ok(keys); + } + }; + + let secret_key = SecretKey::from_slice(&content)?; + let keys = Keys::new(secret_key); + + Ok(keys) +} + fn default_relay_list() -> Vec<(RelayUrl, Option)> { vec![ ( @@ -991,7 +996,7 @@ fn default_relay_list() -> Vec<(RelayUrl, Option)> { Some(RelayMetadata::Write), ), ( - RelayUrl::parse("wss://relay.primal.net/").unwrap(), + RelayUrl::parse("wss://relay.damus.io/").unwrap(), Some(RelayMetadata::Read), ), ( @@ -1003,18 +1008,18 @@ fn default_relay_list() -> Vec<(RelayUrl, Option)> { fn default_messaging_relays() -> Vec { vec![ - RelayUrl::parse("wss://auth.nostr1.com/").unwrap(), + //RelayUrl::parse("wss://auth.nostr1.com/").unwrap(), RelayUrl::parse("wss://nip17.com/").unwrap(), ] } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RelayState { #[default] Idle, Checking, NotConfigured, - Configured, + Configured(Box), } impl RelayState { @@ -1030,8 +1035,11 @@ impl RelayState { matches!(self, RelayState::NotConfigured) } - pub fn configured(&self) -> bool { - matches!(self, RelayState::Configured) + pub fn configured(&self) -> Option<&Event> { + match self { + RelayState::Configured(event) => Some(event), + _ => None, + } } } diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index 6067fb6..fca70c7 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -8,11 +8,15 @@ use smol::lock::RwLock; #[derive(Debug)] pub struct CoopSigner { + /// User's signer signer: RwLock>, - /// Signer's public key + /// User's signer public key signer_pkey: RwLock>, + /// Specific signer for encryption purposes + encryption_signer: RwLock>>, + /// Whether coop is creating a new identity creating: AtomicBool, @@ -30,6 +34,7 @@ impl CoopSigner { Self { signer: RwLock::new(signer.into_nostr_signer()), signer_pkey: RwLock::new(None), + encryption_signer: RwLock::new(None), creating: AtomicBool::new(false), owned: AtomicBool::new(false), } @@ -40,6 +45,11 @@ impl CoopSigner { self.signer.read().await.clone() } + /// Get the encryption signer. + pub async fn get_encryption_signer(&self) -> Option> { + self.encryption_signer.read().await.clone() + } + /// Get public key pub fn public_key(&self) -> Option { self.signer_pkey.read_blocking().to_owned() @@ -64,6 +74,7 @@ impl CoopSigner { let public_key = new_signer.get_public_key().await.ok(); let mut signer = self.signer.write().await; let mut signer_pkey = self.signer_pkey.write().await; + let mut encryption_signer = self.encryption_signer.write().await; // Switch to the new signer *signer = new_signer; @@ -71,9 +82,21 @@ impl CoopSigner { // Update the public key *signer_pkey = public_key; + // Reset the encryption signer + *encryption_signer = None; + // Update the owned flag self.owned.store(owned, Ordering::SeqCst); } + + /// Set the encryption signer. + pub async fn set_encryption_signer(&self, new: T) + where + T: IntoNostrSigner, + { + let mut encryption_signer = self.encryption_signer.write().await; + *encryption_signer = Some(new.into_nostr_signer()); + } } impl NostrSigner for CoopSigner { diff --git a/crates/theme/src/scrollbar_mode.rs b/crates/theme/src/scrollbar_mode.rs index e9fbf76..ab60e03 100644 --- a/crates/theme/src/scrollbar_mode.rs +++ b/crates/theme/src/scrollbar_mode.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)] pub enum ScrollbarMode { #[default] - Scrolling, Hover, + Scrolling, Always, } diff --git a/crates/ui/src/anchored.rs b/crates/ui/src/anchored.rs new file mode 100644 index 0000000..0b471b9 --- /dev/null +++ b/crates/ui/src/anchored.rs @@ -0,0 +1,333 @@ +//! This is a fork of gpui's anchored element that adds support for offsetting +//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs +use gpui::{ + point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half, + InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, + Window, +}; +use smallvec::SmallVec; + +use crate::Anchor; + +/// The state that the anchored element element uses to track its children. +pub struct AnchoredState { + child_layout_ids: SmallVec<[LayoutId; 4]>, +} + +/// An anchored element that can be used to display UI that +/// will avoid overflowing the window bounds. +pub(crate) struct Anchored { + children: SmallVec<[AnyElement; 2]>, + anchor_corner: Anchor, + fit_mode: AnchoredFitMode, + anchor_position: Option>, + position_mode: AnchoredPositionMode, + offset: Option>, +} + +/// anchored gives you an element that will avoid overflowing the window bounds. +/// Its children should have no margin to avoid measurement issues. +pub(crate) fn anchored() -> Anchored { + Anchored { + children: SmallVec::new(), + anchor_corner: Anchor::TopLeft, + fit_mode: AnchoredFitMode::SwitchAnchor, + anchor_position: None, + position_mode: AnchoredPositionMode::Window, + offset: None, + } +} + +#[allow(dead_code)] +impl Anchored { + /// Sets which corner of the anchored element should be anchored to the current position. + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor_corner = anchor; + self + } + + /// Sets the position in window coordinates + /// (otherwise the location the anchored element is rendered is used) + pub fn position(mut self, anchor: Point) -> Self { + self.anchor_position = Some(anchor); + self + } + + /// Offset the final position by this amount. + /// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu. + pub fn offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } + + /// Sets the position mode for this anchored element. Local will have this + /// interpret its [`Anchored::position`] as relative to the parent element. + /// While Window will have it interpret the position as relative to the window. + pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self { + self.position_mode = mode; + self + } + + /// Snap to window edge instead of switching anchor corner when an overflow would occur. + pub fn snap_to_window(mut self) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindow; + self + } + + /// Snap to window edge and leave some margins. + pub fn snap_to_window_with_margin(mut self, edges: impl Into>) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into()); + self + } +} + +impl ParentElement for Anchored { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Element for Anchored { + type PrepaintState = (); + type RequestLayoutState = AnchoredState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let child_layout_ids = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + + let anchored_style = Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }; + + let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx); + + (layout_id, AnchoredState { child_layout_ids }) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + if request_layout.child_layout_ids.is_empty() { + return; + } + + let mut child_min = point(Pixels::MAX, Pixels::MAX); + let mut child_max = Point::default(); + for child_layout_id in &request_layout.child_layout_ids { + let child_bounds = window.layout_bounds(*child_layout_id); + child_min = child_min.min(&child_bounds.origin); + child_max = child_max.max(&child_bounds.bottom_right()); + } + let size: Size = (child_max - child_min).into(); + + let (origin, mut desired) = self.position_mode.get_position_and_bounds( + self.anchor_position, + self.anchor_corner, + size, + bounds, + self.offset, + ); + + let limits = Bounds { + origin: Point::default(), + size: window.viewport_size(), + }; + + if self.fit_mode == AnchoredFitMode::SwitchAnchor { + let mut anchor_corner = self.anchor_corner; + + if desired.left() < limits.left() || desired.right() > limits.right() { + let switched = Bounds::from_corner_and_size( + anchor_corner + .other_side_corner_along(Axis::Horizontal) + .into(), + origin, + size, + ); + if !(switched.left() < limits.left() || switched.right() > limits.right()) { + anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal); + desired = switched + } + } + + if desired.top() < limits.top() || desired.bottom() > limits.bottom() { + let switched = Bounds::from_corner_and_size( + anchor_corner.other_side_corner_along(Axis::Vertical).into(), + origin, + size, + ); + if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) { + desired = switched; + } + } + } + + let client_inset = window.client_inset().unwrap_or(px(0.)); + let edges = match self.fit_mode { + AnchoredFitMode::SnapToWindowWithMargin(edges) => edges, + _ => Edges::default(), + } + .map(|edge| *edge + client_inset); + + // Snap the horizontal edges of the anchored element to the horizontal edges of the window if + // its horizontal bounds overflow, aligning to the left if it is wider than the limits. + if desired.right() > limits.right() { + desired.origin.x -= desired.right() - limits.right() + edges.right; + } + if desired.left() < limits.left() { + desired.origin.x = limits.origin.x + edges.left; + } + + // Snap the vertical edges of the anchored element to the vertical edges of the window if + // its vertical bounds overflow, aligning to the top if it is taller than the limits. + if desired.bottom() > limits.bottom() { + desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom; + } + if desired.top() < limits.top() { + desired.origin.y = limits.origin.y + edges.top; + } + + let offset = desired.origin - bounds.origin; + let offset = point(offset.x.round(), offset.y.round()); + + window.with_element_offset(offset, |window| { + for child in &mut self.children { + child.prepaint(window, cx); + } + }) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + for child in &mut self.children { + child.paint(window, cx); + } + } +} + +impl IntoElement for Anchored { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Which algorithm to use when fitting the anchored element to be inside the window. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredFitMode { + /// Snap the anchored element to the window edge. + SnapToWindow, + /// Snap to window edge and leave some margins. + SnapToWindowWithMargin(Edges), + /// Switch which corner anchor this anchored element is attached to. + SwitchAnchor, +} + +/// Which algorithm to use when positioning the anchored element. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredPositionMode { + /// Position the anchored element relative to the window. + Window, + /// Position the anchored element relative to its parent. + Local, +} + +impl AnchoredPositionMode { + fn get_position_and_bounds( + &self, + anchor_position: Option>, + anchor_corner: Anchor, + size: Size, + bounds: Bounds, + offset: Option>, + ) -> (Point, Bounds) { + let offset = offset.unwrap_or_default(); + + match self { + AnchoredPositionMode::Window => { + let anchor_position = anchor_position.unwrap_or(bounds.origin); + let bounds = + Self::from_corner_and_size(anchor_corner, anchor_position + offset, size); + (anchor_position, bounds) + } + AnchoredPositionMode::Local => { + let anchor_position = anchor_position.unwrap_or_default(); + let bounds = Self::from_corner_and_size( + anchor_corner, + bounds.origin + anchor_position + offset, + size, + ); + (anchor_position, bounds) + } + } + } + + // Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863 + fn from_corner_and_size( + anchor: Anchor, + origin: Point, + size: Size, + ) -> Bounds { + let origin = match anchor { + Anchor::TopLeft => origin, + Anchor::TopCenter => Point { + x: origin.x - size.width.half(), + y: origin.y, + }, + Anchor::TopRight => Point { + x: origin.x - size.width, + y: origin.y, + }, + Anchor::BottomLeft => Point { + x: origin.x, + y: origin.y - size.height, + }, + Anchor::BottomCenter => Point { + x: origin.x - size.width.half(), + y: origin.y - size.height, + }, + Anchor::BottomRight => Point { + x: origin.x - size.width, + y: origin.y - size.height, + }, + }; + + Bounds { origin, size } + } +} diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs deleted file mode 100644 index ce09a67..0000000 --- a/crates/ui/src/dropdown.rs +++ /dev/null @@ -1,811 +0,0 @@ -use gpui::prelude::FluentBuilder; -use gpui::{ - anchored, canvas, deferred, div, px, rems, AnyElement, App, AppContext, Bounds, ClickEvent, - Context, DismissEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, -}; -use theme::ActiveTheme; - -use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::input::clear_button::clear_button; -use crate::list::{List, ListDelegate, ListItem}; -use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized}; - -const CONTEXT: &str = "Dropdown"; - -#[derive(Clone)] -pub enum ListEvent { - /// Single click or move to selected row. - SelectItem(usize), - /// Double click on the row. - ConfirmItem(usize), - // Cancel the selection. - Cancel, -} - -pub fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("up", SelectUp, Some(CONTEXT)), - KeyBinding::new("down", SelectDown, Some(CONTEXT)), - KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), - KeyBinding::new( - "secondary-enter", - Confirm { secondary: true }, - Some(CONTEXT), - ), - KeyBinding::new("escape", Cancel, Some(CONTEXT)), - ]) -} - -/// A trait for items that can be displayed in a dropdown. -pub trait DropdownItem { - type Value: Clone; - fn title(&self) -> SharedString; - /// Customize the display title used to selected item in Dropdown Input. - /// - /// If return None, the title will be used. - fn display_title(&self) -> Option { - None - } - fn value(&self) -> &Self::Value; -} - -impl DropdownItem for String { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - self - } -} - -impl DropdownItem for SharedString { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - self - } -} - -pub trait DropdownDelegate: Sized { - type Item: DropdownItem; - - fn len(&self) -> usize; - - fn is_empty(&self) -> bool { - self.len() == 0 - } - - fn get(&self, ix: usize) -> Option<&Self::Item>; - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - (0..self.len()).find(|&i| self.get(i).is_some_and(|item| item.value() == value)) - } - - fn can_search(&self) -> bool { - false - } - - fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> { - Task::ready(()) - } -} - -impl DropdownDelegate for Vec { - type Item = T; - - fn len(&self) -> usize { - self.len() - } - - fn get(&self, ix: usize) -> Option<&Self::Item> { - self.as_slice().get(ix) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - self.iter().position(|v| v.value() == value) - } -} - -struct DropdownListDelegate { - delegate: D, - dropdown: WeakEntity>, - selected_index: Option, -} - -impl ListDelegate for DropdownListDelegate -where - D: DropdownDelegate + 'static, -{ - type Item = ListItem; - - fn items_count(&self, _: &App) -> usize { - self.delegate.len() - } - - fn render_item( - &self, - ix: usize, - _: &mut gpui::Window, - cx: &mut gpui::Context>, - ) -> Option { - let selected = self.selected_index == Some(ix); - let size = self - .dropdown - .upgrade() - .map_or(Size::Medium, |dropdown| dropdown.read(cx).size); - - if let Some(item) = self.delegate.get(ix) { - let list_item = ListItem::new(("list-item", ix)) - .check_icon(IconName::Check) - .selected(selected) - .input_font_size(size) - .list_size(size) - .child(div().whitespace_nowrap().child(item.title().to_string())); - Some(list_item) - } else { - None - } - } - - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { - let dropdown = self.dropdown.clone(); - cx.defer_in(window, move |_, window, cx| { - _ = dropdown.update(cx, |this, cx| { - this.open = false; - this.focus(window, cx); - }); - }); - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let selected_value = self - .selected_index - .and_then(|ix| self.delegate.get(ix)) - .map(|item| item.value().clone()); - let dropdown = self.dropdown.clone(); - - cx.defer_in(window, move |_, window, cx| { - _ = dropdown.update(cx, |this, cx| { - cx.emit(DropdownEvent::Confirm(selected_value.clone())); - this.selected_value = selected_value; - this.open = false; - this.focus(window, cx); - }); - }); - } - - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - self.dropdown.upgrade().map_or(Task::ready(()), |dropdown| { - dropdown.update(cx, |_, cx| self.delegate.perform_search(query, window, cx)) - }) - } - - fn set_selected_index( - &mut self, - ix: Option, - _: &mut Window, - _: &mut Context>, - ) { - self.selected_index = ix; - } - - fn render_empty(&self, window: &mut Window, cx: &mut Context>) -> impl IntoElement { - if let Some(empty) = self - .dropdown - .upgrade() - .and_then(|dropdown| dropdown.read(cx).empty.as_ref()) - { - empty(window, cx).into_any_element() - } else { - h_flex() - .justify_center() - .py_6() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::Loader).size(px(28.))) - .into_any_element() - } - } -} - -pub enum DropdownEvent { - Confirm(Option<::Value>), -} - -type DropdownStateEmpty = Option AnyElement>>; - -/// State of the [`Dropdown`]. -pub struct DropdownState { - focus_handle: FocusHandle, - list: Entity>>, - size: Size, - empty: DropdownStateEmpty, - /// Store the bounds of the input - bounds: Bounds, - open: bool, - selected_value: Option<::Value>, - _subscriptions: Vec, -} - -/// A Dropdown element. -#[derive(IntoElement)] -pub struct Dropdown { - id: ElementId, - state: Entity>, - size: Size, - icon: Option, - cleanable: bool, - placeholder: Option, - title_prefix: Option, - empty: Option, - width: Length, - menu_width: Length, - disabled: bool, -} - -pub struct SearchableVec { - items: Vec, - matched_items: Vec, -} - -impl SearchableVec { - pub fn new(items: impl Into>) -> Self { - let items = items.into(); - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl DropdownDelegate for SearchableVec { - type Item = T; - - fn len(&self) -> usize { - self.matched_items.len() - } - - fn get(&self, ix: usize) -> Option<&Self::Item> { - self.matched_items.get(ix) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - for (ix, item) in self.matched_items.iter().enumerate() { - if item.value() == value { - return Some(ix); - } - } - - None - } - - fn can_search(&self) -> bool { - true - } - - fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> { - self.matched_items = self - .items - .iter() - .filter(|item| item.title().to_lowercase().contains(&query.to_lowercase())) - .cloned() - .collect(); - - Task::ready(()) - } -} - -impl From> for SearchableVec { - fn from(items: Vec) -> Self { - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl DropdownState -where - D: DropdownDelegate + 'static, -{ - pub fn new( - delegate: D, - selected_index: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - let delegate = DropdownListDelegate { - delegate, - dropdown: cx.entity().downgrade(), - selected_index, - }; - - let searchable = delegate.delegate.can_search(); - - let list = cx.new(|cx| { - let mut list = List::new(delegate, window, cx) - .max_h(rems(20.)) - .reset_on_cancel(false); - if !searchable { - list = list.no_query(); - } - list - }); - - let _subscriptions = vec![ - cx.on_blur(&list.focus_handle(cx), window, Self::on_blur), - cx.on_blur(&focus_handle, window, Self::on_blur), - ]; - - let mut this = Self { - focus_handle, - list, - size: Size::Medium, - selected_value: None, - open: false, - bounds: Bounds::default(), - empty: None, - _subscriptions, - }; - this.set_selected_index(selected_index, window, cx); - this - } - - pub fn empty(mut self, f: F) -> Self - where - E: IntoElement, - F: Fn(&Window, &App) -> E + 'static, - { - self.empty = Some(Box::new(move |window, cx| f(window, cx).into_any_element())); - self - } - - pub fn set_selected_index( - &mut self, - selected_index: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.list.update(cx, |list, cx| { - list.set_selected_index(selected_index, window, cx); - }); - self.update_selected_value(window, cx); - } - - pub fn set_selected_value( - &mut self, - selected_value: &::Value, - window: &mut Window, - cx: &mut Context, - ) where - <::Item as DropdownItem>::Value: PartialEq, - { - let delegate = self.list.read(cx).delegate(); - let selected_index = delegate.delegate.position(selected_value); - self.set_selected_index(selected_index, window, cx); - } - - pub fn selected_index(&self, cx: &App) -> Option { - self.list.read(cx).selected_index() - } - - fn update_selected_value(&mut self, _: &Window, cx: &App) { - self.selected_value = self - .selected_index(cx) - .and_then(|ix| self.list.read(cx).delegate().delegate.get(ix)) - .map(|item| item.value().clone()); - } - - pub fn selected_value(&self) -> Option<&::Value> { - self.selected_value.as_ref() - } - - pub fn focus(&self, window: &mut Window, cx: &mut App) { - self.focus_handle.focus(window, cx); - } - - fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - // When the dropdown and dropdown menu are both not focused, close the dropdown menu. - if self.list.focus_handle(cx).is_focused(window) || self.focus_handle.is_focused(window) { - return; - } - - self.open = false; - cx.notify(); - } - - fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - if !self.open { - return; - } - - self.list.focus_handle(cx).focus(window, cx); - cx.propagate(); - } - - fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - if !self.open { - self.open = true; - } - - self.list.focus_handle(cx).focus(window, cx); - cx.propagate(); - } - - fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - // Propagate the event to the parent view, for example to the Modal to support ENTER to confirm. - cx.propagate(); - - if !self.open { - self.open = true; - cx.notify(); - } else { - self.list.focus_handle(cx).focus(window, cx); - } - } - - fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - cx.stop_propagation(); - - self.open = !self.open; - if self.open { - self.list.focus_handle(cx).focus(window, cx); - } - cx.notify(); - } - - fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) { - if !self.open { - cx.propagate(); - } - - self.open = false; - cx.notify(); - } - - fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - self.set_selected_index(None, window, cx); - cx.emit(DropdownEvent::Confirm(None)); - } - - /// Set the items for the dropdown. - pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) - where - D: DropdownDelegate + 'static, - { - self.list.update(cx, |list, _| { - list.delegate_mut().delegate = items; - }); - } -} - -impl Render for DropdownState -where - D: DropdownDelegate + 'static, -{ - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } -} - -impl Dropdown -where - D: DropdownDelegate + 'static, -{ - pub fn new(state: &Entity>) -> Self { - Self { - id: ("dropdown", state.entity_id()).into(), - state: state.clone(), - placeholder: None, - size: Size::Medium, - icon: None, - cleanable: false, - title_prefix: None, - empty: None, - width: Length::Auto, - menu_width: Length::Auto, - disabled: false, - } - } - - /// Set the width of the dropdown input, default: Length::Auto - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - /// Set the width of the dropdown menu, default: Length::Auto - pub fn menu_width(mut self, width: impl Into) -> Self { - self.menu_width = width.into(); - self - } - - /// Set the placeholder for display when dropdown value is empty. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = Some(placeholder.into()); - self - } - - /// Set the right icon for the dropdown input, instead of the default arrow icon. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - /// Set title prefix for the dropdown. - /// - /// e.g.: Country: United States - /// - /// You should set the label is `Country: ` - pub fn title_prefix(mut self, prefix: impl Into) -> Self { - self.title_prefix = Some(prefix.into()); - self - } - - /// Set true to show the clear button when the input field is not empty. - pub fn cleanable(mut self) -> Self { - self.cleanable = true; - self - } - - /// Set the disable state for the dropdown. - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - pub fn empty(mut self, el: impl IntoElement) -> Self { - self.empty = Some(el.into_any_element()); - self - } - - /// Returns the title element for the dropdown input. - fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement { - let default_title = div() - .text_color(cx.theme().text_accent) - .child( - self.placeholder - .clone() - .unwrap_or_else(|| "Please select".into()), - ) - .when(self.disabled, |this| this.text_color(cx.theme().text_muted)); - - let Some(selected_index) = &self.state.read(cx).selected_index(cx) else { - return default_title; - }; - - let Some(title) = self - .state - .read(cx) - .list - .read(cx) - .delegate() - .delegate - .get(*selected_index) - .map(|item| { - if let Some(el) = item.display_title() { - el - } else if let Some(prefix) = self.title_prefix.as_ref() { - format!("{}{}", prefix, item.title()).into_any_element() - } else { - item.title().into_any_element() - } - }) - else { - return default_title; - }; - - div() - .when(self.disabled, |this| this.text_color(cx.theme().text_muted)) - .child(title) - } -} - -impl Sizable for Dropdown -where - D: DropdownDelegate + 'static, -{ - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl EventEmitter> for DropdownState where D: DropdownDelegate + 'static {} -impl EventEmitter for DropdownState where D: DropdownDelegate + 'static {} -impl Focusable for DropdownState -where - D: DropdownDelegate, -{ - fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.open { - self.list.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} -impl Focusable for Dropdown -where - D: DropdownDelegate, -{ - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.state.focus_handle(cx) - } -} - -impl RenderOnce for Dropdown -where - D: DropdownDelegate + 'static, -{ - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_focused = self.focus_handle(cx).is_focused(window); - // If the size has change, set size to self.list, to change the QueryInput size. - let old_size = self.state.read(cx).list.read(cx).size; - if old_size != self.size { - self.state - .read(cx) - .list - .clone() - .update(cx, |this, cx| this.set_size(self.size, window, cx)); - self.state.update(cx, |this, _| { - this.size = self.size; - }); - } - - let state = self.state.read(cx); - let show_clean = self.cleanable && state.selected_index(cx).is_some(); - let bounds = state.bounds; - let allow_open = !(state.open || self.disabled); - let outline_visible = state.open || is_focused && !self.disabled; - let popup_radius = cx.theme().radius.min(px(8.)); - - div() - .id(self.id.clone()) - .key_context(CONTEXT) - .track_focus(&self.focus_handle(cx)) - .on_action(window.listener_for(&self.state, DropdownState::up)) - .on_action(window.listener_for(&self.state, DropdownState::down)) - .on_action(window.listener_for(&self.state, DropdownState::enter)) - .on_action(window.listener_for(&self.state, DropdownState::escape)) - .size_full() - .relative() - .input_font_size(self.size) - .child( - div() - .id(ElementId::Name(format!("{}-input", self.id).into())) - .relative() - .flex() - .items_center() - .justify_between() - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .when(cx.theme().shadow, |this| this.shadow_sm()) - .overflow_hidden() - .input_font_size(self.size) - .map(|this| match self.width { - Length::Definite(l) => this.flex_none().w(l), - Length::Auto => this.w_full(), - }) - .when(outline_visible, |this| this.border_color(cx.theme().ring)) - .input_size(self.size) - .when(allow_open, |this| { - this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu)) - }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_1() - .child( - div() - .w_full() - .overflow_hidden() - .whitespace_nowrap() - .truncate() - .child(self.display_title(window, cx)), - ) - .when(show_clean, |this| { - this.child(clear_button(cx).map(|this| { - if self.disabled { - this.disabled(true) - } else { - this.on_click( - window.listener_for(&self.state, DropdownState::clean), - ) - } - })) - }) - .when(!show_clean, |this| { - let icon = match self.icon.clone() { - Some(icon) => icon, - None => { - if state.open { - Icon::new(IconName::CaretUp) - } else { - Icon::new(IconName::CaretDown) - } - } - }; - - this.child(icon.xsmall().text_color(match self.disabled { - true => cx.theme().text_placeholder, - false => cx.theme().text_muted, - })) - }), - ) - .child( - canvas( - { - let state = self.state.clone(); - move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ), - ) - .when(state.open, |this| { - this.child( - deferred( - anchored().snap_to_window_with_margin(px(8.)).child( - div() - .occlude() - .map(|this| match self.menu_width { - Length::Auto => this.w(bounds.size.width), - Length::Definite(w) => this.w(w), - }) - .child( - v_flex() - .occlude() - .mt_1p5() - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(popup_radius) - .when(cx.theme().shadow, |this| this.shadow_md()) - .child(state.list.clone()), - ) - .on_mouse_down_out(window.listener_for( - &self.state, - |this, _, window, cx| { - this.escape(&Cancel, window, cx); - }, - )), - ), - ) - .with_priority(1), - ) - }) - } -} diff --git a/crates/ui/src/geometry.rs b/crates/ui/src/geometry.rs new file mode 100644 index 0000000..4f6fbe7 --- /dev/null +++ b/crates/ui/src/geometry.rs @@ -0,0 +1,294 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels}; +use serde::{Deserialize, Serialize}; + +/// A enum for defining the placement of the element. +/// +/// See also: [`Side`] if you need to define the left, right side. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Placement { + #[serde(rename = "top")] + Top, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Display for Placement { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Placement::Top => write!(f, "Top"), + Placement::Bottom => write!(f, "Bottom"), + Placement::Left => write!(f, "Left"), + Placement::Right => write!(f, "Right"), + } + } +} + +impl Placement { + #[inline] + pub fn is_horizontal(&self) -> bool { + matches!(self, Placement::Left | Placement::Right) + } + + #[inline] + pub fn is_vertical(&self) -> bool { + matches!(self, Placement::Top | Placement::Bottom) + } + + #[inline] + pub fn axis(&self) -> Axis { + match self { + Placement::Top | Placement::Bottom => Axis::Vertical, + Placement::Left | Placement::Right => Axis::Horizontal, + } + } +} + +/// The anchor position of an element. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Anchor { + #[default] + #[serde(rename = "top-left")] + TopLeft, + #[serde(rename = "top-center")] + TopCenter, + #[serde(rename = "top-right")] + TopRight, + #[serde(rename = "bottom-left")] + BottomLeft, + #[serde(rename = "bottom-center")] + BottomCenter, + #[serde(rename = "bottom-right")] + BottomRight, +} + +impl Display for Anchor { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Anchor::TopLeft => write!(f, "TopLeft"), + Anchor::TopCenter => write!(f, "TopCenter"), + Anchor::TopRight => write!(f, "TopRight"), + Anchor::BottomLeft => write!(f, "BottomLeft"), + Anchor::BottomCenter => write!(f, "BottomCenter"), + Anchor::BottomRight => write!(f, "BottomRight"), + } + } +} + +impl Anchor { + /// Returns true if the anchor is at the top. + #[inline] + pub fn is_top(&self) -> bool { + matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight) + } + + /// Returns true if the anchor is at the bottom. + #[inline] + pub fn is_bottom(&self) -> bool { + matches!( + self, + Self::BottomLeft | Self::BottomCenter | Self::BottomRight + ) + } + + /// Returns true if the anchor is at the left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::TopLeft | Self::BottomLeft) + } + + /// Returns true if the anchor is at the right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::TopRight | Self::BottomRight) + } + + /// Returns true if the anchor is at the center. + #[inline] + pub fn is_center(&self) -> bool { + matches!(self, Self::TopCenter | Self::BottomCenter) + } + + /// Swaps the vertical position of the anchor. + pub fn swap_vertical(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::BottomRight => Anchor::TopRight, + } + } + + /// Swaps the horizontal position of the anchor. + pub fn swap_horizontal(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::TopRight, + Anchor::TopCenter => Anchor::TopCenter, + Anchor::TopRight => Anchor::TopLeft, + Anchor::BottomLeft => Anchor::BottomRight, + Anchor::BottomCenter => Anchor::BottomCenter, + Anchor::BottomRight => Anchor::BottomLeft, + } + } + + pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor { + match axis { + Axis::Vertical => match self { + Self::TopLeft => Self::BottomLeft, + Self::TopCenter => Self::BottomCenter, + Self::TopRight => Self::BottomRight, + Self::BottomLeft => Self::TopLeft, + Self::BottomCenter => Self::TopCenter, + Self::BottomRight => Self::TopRight, + }, + Axis::Horizontal => match self { + Self::TopLeft => Self::TopRight, + Self::TopCenter => Self::TopCenter, + Self::TopRight => Self::TopLeft, + Self::BottomLeft => Self::BottomRight, + Self::BottomCenter => Self::BottomCenter, + Self::BottomRight => Self::BottomLeft, + }, + } + } +} + +impl From for Anchor { + fn from(corner: Corner) -> Self { + match corner { + Corner::TopLeft => Anchor::TopLeft, + Corner::TopRight => Anchor::TopRight, + Corner::BottomLeft => Anchor::BottomLeft, + Corner::BottomRight => Anchor::BottomRight, + } + } +} + +impl From for Corner { + fn from(anchor: Anchor) -> Self { + match anchor { + Anchor::TopLeft => Corner::TopLeft, + Anchor::TopRight => Corner::TopRight, + Anchor::BottomLeft => Corner::BottomLeft, + Anchor::BottomRight => Corner::BottomRight, + Anchor::TopCenter => Corner::TopLeft, + Anchor::BottomCenter => Corner::BottomLeft, + } + } +} + +/// A enum for defining the side of the element. +/// +/// See also: [`Placement`] if you need to define the 4 edges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Side { + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Side { + /// Returns true if the side is left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + /// Returns true if the side is right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// A trait to extend the [`Axis`] enum with utility methods. +pub trait AxisExt { + #[allow(clippy::wrong_self_convention)] + fn is_horizontal(self) -> bool; + #[allow(clippy::wrong_self_convention)] + fn is_vertical(self) -> bool; +} + +impl AxisExt for Axis { + #[inline] + fn is_horizontal(self) -> bool { + self == Axis::Horizontal + } + + #[inline] + fn is_vertical(self) -> bool { + self == Axis::Vertical + } +} + +/// A trait for converting [`Pixels`] to `f32` and `f64`. +pub trait PixelsExt { + fn as_f32(&self) -> f32; + #[allow(clippy::wrong_self_convention)] + fn as_f64(self) -> f64; +} +impl PixelsExt for Pixels { + fn as_f32(&self) -> f32 { + f32::from(self) + } + + fn as_f64(self) -> f64 { + f64::from(self) + } +} + +/// A trait to extend the [`Length`] enum with utility methods. +pub trait LengthExt { + /// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`. + /// + /// If the [`Length`] is [`Length::Auto`], it returns `None`. + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option; +} + +impl LengthExt for Length { + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option { + match self { + Length::Auto => None, + Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)), + } + } +} + +/// A struct for defining the edges of an element. +/// +/// A extend version of [`gpui::Edges`] to serialize/deserialize. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] +#[repr(C)] +pub struct Edges { + /// The size of the top edge. + pub top: T, + /// The size of the right edge. + pub right: T, + /// The size of the bottom edge. + pub bottom: T, + /// The size of the left edge. + pub left: T, +} + +impl Edges +where + T: Clone + Debug + Default + PartialEq, +{ + /// Creates a new `Edges` instance with all edges set to the same value. + pub fn all(value: T) -> Self { + Self { + top: value.clone(), + right: value.clone(), + bottom: value.clone(), + left: value, + } + } +} diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 01eb604..1dca603 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -51,6 +51,7 @@ pub enum IconName { PanelRightOpen, PanelBottom, PanelBottomOpen, + PaperPlaneFill, Warning, WindowClose, WindowMaximize, @@ -106,6 +107,7 @@ impl IconName { Self::PanelRightOpen => "icons/panel-right-open.svg", Self::PanelBottom => "icons/panel-bottom.svg", Self::PanelBottomOpen => "icons/panel-bottom-open.svg", + Self::PaperPlaneFill => "icons/paper-plane-fill.svg", Self::Warning => "icons/warning.svg", Self::WindowClose => "icons/window-close.svg", Self::WindowMaximize => "icons/window-maximize.svg", diff --git a/crates/ui/src/index_path.rs b/crates/ui/src/index_path.rs new file mode 100644 index 0000000..987412e --- /dev/null +++ b/crates/ui/src/index_path.rs @@ -0,0 +1,69 @@ +use std::fmt::{Debug, Display}; + +use gpui::ElementId; + +/// Represents an index path in a list, which consists of a section index, +/// +/// The default values for section, row, and column are all set to 0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexPath { + /// The section index. + pub section: usize, + /// The item index in the section. + pub row: usize, + /// The column index. + pub column: usize, +} + +impl From for ElementId { + fn from(path: IndexPath) -> Self { + ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into()) + } +} + +impl Display for IndexPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "IndexPath(section: {}, row: {}, column: {})", + self.section, self.row, self.column + ) + } +} + +impl IndexPath { + /// Create a new index path with the specified section and row. + /// + /// The `section` is set to 0 by default. + /// The `column` is set to 0 by default. + pub fn new(row: usize) -> Self { + IndexPath { + section: 0, + row, + ..Default::default() + } + } + + /// Set the section for the index path. + pub fn section(mut self, section: usize) -> Self { + self.section = section; + self + } + + /// Set the row for the index path. + pub fn row(mut self, row: usize) -> Self { + self.row = row; + self + } + + /// Set the column for the index path. + pub fn column(mut self, column: usize) -> Self { + self.column = column; + self + } + + /// Check if the self is equal to the given index path (Same section and row). + pub fn eq_row(&self, index: IndexPath) -> bool { + self.section == index.section && self.row == index.row + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index dce8d13..b6e8b97 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,9 +1,11 @@ +pub use anchored::*; pub use element_ext::ElementExt; pub use event::InteractiveElementExt; pub use focusable::FocusableCycle; +pub use geometry::*; pub use icon::*; +pub use index_path::IndexPath; pub use kbd::*; -pub use menu::{context_menu, popup_menu}; pub use root::{window_paddings, Root}; pub use styled::*; pub use window_ext::*; @@ -16,7 +18,6 @@ pub mod avatar; pub mod button; pub mod checkbox; pub mod divider; -pub mod dropdown; pub mod history; pub mod indicator; pub mod input; @@ -30,10 +31,13 @@ pub mod skeleton; pub mod switch; pub mod tooltip; +mod anchored; mod element_ext; mod event; mod focusable; +mod geometry; mod icon; +mod index_path; mod kbd; mod root; mod styled; @@ -44,7 +48,6 @@ mod window_ext; /// This must be called before using any of the UI components. /// You can initialize the UI module at your application's entry point. pub fn init(cx: &mut gpui::App) { - dropdown::init(cx); input::init(cx); list::init(cx); modal::init(cx); diff --git a/crates/ui/src/list/cache.rs b/crates/ui/src/list/cache.rs new file mode 100644 index 0000000..3de7a8c --- /dev/null +++ b/crates/ui/src/list/cache.rs @@ -0,0 +1,221 @@ +use std::rc::Rc; + +use gpui::{App, Pixels, Size}; + +use crate::IndexPath; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RowEntry { + Entry(IndexPath), + SectionHeader(usize), + SectionFooter(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct MeasuredEntrySize { + pub(crate) item_size: Size, + pub(crate) section_header_size: Size, + pub(crate) section_footer_size: Size, +} + +impl RowEntry { + #[inline] + #[allow(unused)] + pub(crate) fn is_section_header(&self) -> bool { + matches!(self, RowEntry::SectionHeader(_)) + } + + pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool { + match self { + RowEntry::Entry(index_path) => index_path == path, + RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false, + } + } + + #[allow(unused)] + pub(crate) fn index(&self) -> IndexPath { + match self { + RowEntry::Entry(index_path) => *index_path, + RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix), + RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix), + } + } + + #[inline] + #[allow(unused)] + pub(crate) fn is_section_footer(&self) -> bool { + matches!(self, RowEntry::SectionFooter(_)) + } + + #[inline] + pub(crate) fn is_entry(&self) -> bool { + matches!(self, RowEntry::Entry(_)) + } + + #[inline] + #[allow(unused)] + pub(crate) fn section_ix(&self) -> Option { + match self { + RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix), + _ => None, + } + } +} + +#[derive(Default, Clone)] +pub(crate) struct RowsCache { + /// Only have section's that have rows. + pub(crate) entities: Rc>, + pub(crate) items_count: usize, + /// The sections, the item is number of rows in each section. + pub(crate) sections: Rc>, + pub(crate) entries_sizes: Rc>>, + measured_size: MeasuredEntrySize, +} + +impl RowsCache { + pub(crate) fn get(&self, flatten_ix: usize) -> Option { + self.entities.get(flatten_ix).cloned() + } + + /// Returns the number of flattened rows (Includes header, item, footer). + pub(crate) fn len(&self) -> usize { + self.entities.len() + } + + /// Return the number of items in the cache. + pub(crate) fn items_count(&self) -> usize { + self.items_count + } + + /// Returns the index of the Entry with given path in the flattened rows. + pub(crate) fn position_of(&self, path: &IndexPath) -> Option { + self.entities + .iter() + .position(|p| p.is_entry() && p.eq_index_path(path)) + } + + /// Return prev row, if the row is the first in the first section, goes to the last row. + /// + /// Empty rows section are skipped. + pub(crate) fn prev(&self, path: Option) -> IndexPath { + let path = path.unwrap_or_default(); + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(path) = self + .entities + .iter() + .take(pos) + .rev() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path + } else { + self.entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + } + + /// Returns the next row, if the row is the last in the last section, goes to the first row. + /// + /// Empty rows section are skipped. + pub(crate) fn next(&self, path: Option) -> IndexPath { + let Some(mut path) = path else { + return IndexPath::default(); + }; + + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(next_path) = self + .entities + .iter() + .skip(pos + 1) + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path = next_path; + } else { + path = self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + + path + } + + pub(crate) fn prepare_if_needed( + &mut self, + sections_count: usize, + measured_size: MeasuredEntrySize, + cx: &App, + rows_count_f: F, + ) where + F: Fn(usize, &App) -> usize, + { + let mut new_sections = vec![]; + for section_ix in 0..sections_count { + new_sections.push(rows_count_f(section_ix, cx)); + } + + let need_update = new_sections != *self.sections || self.measured_size != measured_size; + + if !need_update { + return; + } + + let mut entries_sizes = vec![]; + let mut total_items_count = 0; + self.measured_size = measured_size; + self.sections = Rc::new(new_sections); + self.entities = Rc::new( + self.sections + .iter() + .enumerate() + .flat_map(|(section, items_count)| { + total_items_count += items_count; + let mut children = vec![]; + if *items_count == 0 { + return children; + } + + children.push(RowEntry::SectionHeader(section)); + entries_sizes.push(measured_size.section_header_size); + for row in 0..*items_count { + children.push(RowEntry::Entry(IndexPath { + section, + row, + ..Default::default() + })); + entries_sizes.push(measured_size.item_size); + } + children.push(RowEntry::SectionFooter(section)); + entries_sizes.push(measured_size.section_footer_size); + children + }) + .collect(), + ); + self.entries_sizes = Rc::new(entries_sizes); + self.items_count = total_items_count; + } +} diff --git a/crates/ui/src/list/delegate.rs b/crates/ui/src/list/delegate.rs new file mode 100644 index 0000000..2899d2f --- /dev/null +++ b/crates/ui/src/list/delegate.rs @@ -0,0 +1,171 @@ +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window}; +use theme::ActiveTheme; + +use crate::list::loading::Loading; +use crate::list::ListState; +use crate::{h_flex, Icon, IconName, IndexPath, Selectable}; + +/// A delegate for the List. +#[allow(unused)] +pub trait ListDelegate: Sized + 'static { + type Item: Selectable + IntoElement; + + /// When Query Input change, this method will be called. + /// You can perform search here. + fn perform_search( + &mut self, + query: &str, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + Task::ready(()) + } + + /// Return the number of sections in the list, default is 1. + /// + /// Min value is 1. + fn sections_count(&self, cx: &App) -> usize { + 1 + } + + /// Return the number of items in the section at the given index. + /// + /// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items, + /// the section header and footer will also be skipped. + fn items_count(&self, section: usize, cx: &App) -> usize; + + /// Render the item at the given index. + /// + /// Return None will skip the item. + /// + /// NOTE: Every item should have same height. + fn render_item( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context>, + ) -> Option; + + /// Render the section header at the given index, default is None. + /// + /// NOTE: Every header should have same height. + fn render_section_header( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Render the section footer at the given index, default is None. + /// + /// NOTE: Every footer should have same height. + fn render_section_footer( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Return a Element to show when list is empty. + fn render_empty( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_color(cx.theme().text_muted.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size_12()) + .into_any_element() + } + + /// Returns Some(AnyElement) to render the initial state of the list. + /// + /// This can be used to show a view for the list before the user has + /// interacted with it. + /// + /// For example: The last search results, or the last selected item. + /// + /// Default is None, that means no initial state. + fn render_initial( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None + } + + /// Returns the loading state to show the loading view. + fn loading(&self, cx: &App) -> bool { + false + } + + /// Returns a Element to show when loading, default is built-in Skeleton + /// loading view. + fn render_loading( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + Loading + } + + /// Set the selected index, just store the ix, don't confirm. + fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ); + + /// Set the index of the item that has been right clicked. + fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ) { + } + + /// Set the confirm and give the selected index, + /// this is means user have clicked the item or pressed Enter. + /// + /// This will always to `set_selected_index` before confirm. + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + } + + /// Cancel the selection, e.g.: Pressed ESC. + fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} + + /// Return true to enable load more data when scrolling to the bottom. + /// + /// Default: false + fn has_more(&self, cx: &App) -> bool { + false + } + + /// Returns a threshold value (n entities), of course, + /// when scrolling to the bottom, the remaining number of rows + /// triggers `load_more`. + /// + /// This should smaller than the total number of first load rows. + /// + /// Default: 20 entities (section header, footer and row) + fn load_more_threshold(&self) -> usize { + 20 + } + + /// Load more data when the table is scrolled to the bottom. + /// + /// This will performed in a background task. + /// + /// This is always called when the table is near the bottom, + /// so you must check if there is more data to load or lock + /// the loading state. + fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 79d52ab..5aab352 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -3,21 +3,23 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, - MouseButton, MouseDownEvent, ParentElement, Render, ScrollStrategy, Styled, Subscription, Task, - UniformListScrollHandle, Window, + div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context, + DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, + ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window, }; use smol::Timer; use theme::ActiveTheme; -use super::loading::Loading; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::input::{InputEvent, InputState, TextInput}; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{v_flex, Icon, IconName, Sizable as _, Size}; +use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; +use crate::list::ListDelegate; +use crate::scroll::{Scrollbar, ScrollbarHandle}; +use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt}; -pub fn init(cx: &mut App) { +pub(crate) fn init(cx: &mut App) { let context: Option<&str> = Some("List"); cx.bind_keys([ KeyBinding::new("escape", Cancel, context), @@ -31,138 +33,57 @@ pub fn init(cx: &mut App) { #[derive(Clone)] pub enum ListEvent { /// Move to select item. - Select(usize), + Select(IndexPath), /// Click on item or pressed Enter. - Confirm(usize), + Confirm(IndexPath), /// Pressed ESC to deselect the item. Cancel, } -/// A delegate for the List. -#[allow(unused)] -pub trait ListDelegate: Sized + 'static { - type Item: IntoElement; - - /// When Query Input change, this method will be called. - /// You can perform search here. - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - Task::ready(()) - } - - /// Return the number of items in the list. - fn items_count(&self, cx: &App) -> usize; - - /// Render the item at the given index. - /// - /// Return None will skip the item. - fn render_item( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context>, - ) -> Option; - - /// Return a Element to show when list is empty. - fn render_empty(&self, window: &mut Window, cx: &mut Context>) -> impl IntoElement { - div() - } - - /// Returns Some(AnyElement) to render the initial state of the list. - /// - /// This can be used to show a view for the list before the user has interacted with it. - /// - /// For example: The last search results, or the last selected item. - /// - /// Default is None, that means no initial state. - fn render_initial( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None - } - - /// Returns the loading state to show the loading view. - fn loading(&self, cx: &App) -> bool { - false - } - - /// Returns a Element to show when loading, default is built-in Skeleton loading view. - fn render_loading( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - Loading - } - - /// Set the selected index, just store the ix, don't confirm. - fn set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context>, - ); - - /// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter. - /// - /// This will always to `set_selected_index` before confirm. - fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {} - - /// Cancel the selection, e.g.: Pressed ESC. - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} - - /// Return true to enable load more data when scrolling to the bottom. - /// - /// Default: true - fn can_load_more(&self, cx: &App) -> bool { - true - } - - /// Returns a threshold value (n rows), of course, when scrolling to the bottom, - /// the remaining number of rows triggers `load_more`. - /// This should smaller than the total number of first load rows. - /// - /// Default: 20 rows - fn load_more_threshold(&self) -> usize { - 20 - } - - /// Load more data when the table is scrolled to the bottom. - /// - /// This will performed in a background task. - /// - /// This is always called when the table is near the bottom, - /// so you must check if there is more data to load or lock the loading state. - fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +struct ListOptions { + size: Size, + scrollbar_visible: bool, + search_placeholder: Option, + max_height: Option, + paddings: EdgesRefinement, } -pub struct List { - focus_handle: FocusHandle, +impl Default for ListOptions { + fn default() -> Self { + Self { + size: Size::default(), + scrollbar_visible: true, + max_height: None, + search_placeholder: None, + paddings: EdgesRefinement::default(), + } + } +} + +/// The state for List. +/// +/// List required all items has the same height. +pub struct ListState { + pub(crate) focus_handle: FocusHandle, + pub(crate) query_input: Entity, + options: ListOptions, delegate: D, - max_height: Option, - query_input: Option>, last_query: Option, - selectable: bool, - querying: bool, - scrollbar_visible: bool, - vertical_scroll_handle: UniformListScrollHandle, - scrollbar_state: ScrollbarState, - pub(crate) size: Size, - selected_index: Option, - right_clicked_index: Option, + scroll_handle: UniformListScrollHandle, + rows_cache: RowsCache, + selected_index: Option, + item_to_measure_index: IndexPath, + deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>, + mouse_right_clicked_index: Option, reset_on_cancel: bool, + searchable: bool, + selectable: bool, _search_task: Task<()>, _load_more_task: Task<()>, _query_input_subscription: Subscription, } -impl List +impl ListState where D: ListDelegate, { @@ -173,18 +94,18 @@ where Self { focus_handle: cx.focus_handle(), + options: ListOptions::default(), delegate, - query_input: Some(query_input), + rows_cache: RowsCache::default(), + query_input, last_query: None, selected_index: None, - right_clicked_index: None, - vertical_scroll_handle: UniformListScrollHandle::new(), - scrollbar_state: ScrollbarState::default(), - max_height: None, - scrollbar_visible: true, selectable: true, - querying: false, - size: Size::default(), + searchable: false, + item_to_measure_index: IndexPath::default(), + deferred_scroll_to_index: None, + mouse_right_clicked_index: None, + scroll_handle: UniformListScrollHandle::new(), reset_on_cancel: true, _search_task: Task::ready(()), _load_more_task: Task::ready(()), @@ -192,25 +113,17 @@ where } } - /// Set the size - pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context) { - self.size = size; - } - - pub fn max_h(mut self, height: impl Into) -> Self { - self.max_height = Some(height.into()); + /// Sets whether the list is searchable, default is `false`. + /// + /// When `true`, there will be a search input at the top of the list. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; self } - /// Set the visibility of the scrollbar, default is true. - pub fn scrollbar_visible(mut self, visible: bool) -> Self { - self.scrollbar_visible = visible; - self - } - - pub fn no_query(mut self) -> Self { - self.query_input = None; - self + pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { + self.searchable = searchable; + cx.notify(); } /// Sets whether the list is selectable, default is true. @@ -219,20 +132,10 @@ where self } - pub fn set_query_input( - &mut self, - query_input: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._query_input_subscription = - cx.subscribe_in(&query_input, window, Self::on_query_input_event); - self.query_input = Some(query_input); - } - - /// Get the query input entity. - pub fn query_input(&self) -> Option<&Entity> { - self.query_input.as_ref() + /// Sets whether the list is selectable, default is true. + pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context) { + self.selectable = selectable; + cx.notify(); } pub fn delegate(&self) -> &D { @@ -243,57 +146,106 @@ where &mut self.delegate } + /// Focus the list, if the list is searchable, focus the search input. pub fn focus(&mut self, window: &mut Window, cx: &mut App) { self.focus_handle(cx).focus(window, cx); } - /// Set the selected index of the list, this will also scroll to the selected item. - pub fn set_selected_index( + /// Return true if either the list or the search input is focused. + #[allow(dead_code)] + pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool { + self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window) + } + + /// Set the selected index of the list, + /// this will also scroll to the selected item. + pub(crate) fn _set_selected_index( &mut self, - ix: Option, + ix: Option, window: &mut Window, cx: &mut Context, ) { + if !self.selectable { + return; + } + self.selected_index = ix; self.delegate.set_selected_index(ix, window, cx); self.scroll_to_selected_item(window, cx); } - pub fn selected_index(&self) -> Option { + /// Set the selected index of the list, + /// this method will not scroll to the selected item. + pub fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.selected_index = ix; + self.delegate.set_selected_index(ix, window, cx); + } + + pub fn selected_index(&self) -> Option { self.selected_index } - fn render_scrollbar( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - if !self.scrollbar_visible { - return None; - } + /// Set the index of the item that has been right clicked. + pub fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.mouse_right_clicked_index = ix; + self.delegate.set_right_clicked_index(ix, window, cx); + } - Some(Scrollbar::uniform_scroll( - &self.scrollbar_state, - &self.vertical_scroll_handle, - )) + /// Returns the index of the item that has been right clicked. + pub fn right_clicked_index(&self) -> Option { + self.mouse_right_clicked_index + } + + /// Set a specific list item for measurement. + pub fn set_item_to_measure_index( + &mut self, + ix: IndexPath, + _: &mut Window, + cx: &mut Context, + ) { + self.item_to_measure_index = ix; + cx.notify(); } /// Scroll to the item at the given index. - pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + pub fn scroll_to_item( + &mut self, + ix: IndexPath, + strategy: ScrollStrategy, + _: &mut Window, + cx: &mut Context, + ) { + if ix.section == 0 && ix.row == 0 { + // If the item is the first item, scroll to the top. + let mut offset = self.scroll_handle.offset(); + offset.y = px(0.); + self.scroll_handle.set_offset(offset); + cx.notify(); + return; + } + self.deferred_scroll_to_index = Some((ix, strategy)); cx.notify(); } /// Get scroll handle pub fn scroll_handle(&self) -> &UniformListScrollHandle { - &self.vertical_scroll_handle + &self.scroll_handle } - fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context) { + pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context) { if let Some(ix) = self.selected_index { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top)); + cx.notify(); } } @@ -308,33 +260,31 @@ where InputEvent::Change => { let text = state.read(cx).value(); let text = text.trim().to_string(); - if Some(&text) == self.last_query.as_ref() { return; } - self.set_querying(true, window, cx); + self.set_searching(true, window, cx); let search = self.delegate.perform_search(&text, window, cx); - if self.delegate.items_count(cx) > 0 { - self.set_selected_index(Some(0), window, cx); + if self.rows_cache.len() > 0 { + self._set_selected_index(Some(IndexPath::default()), window, cx); } else { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self._search_task = cx.spawn_in(window, async move |this, window| { search.await; _ = this.update_in(window, |this, _, _| { - this.vertical_scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); this.last_query = Some(text); }); // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; _ = this.update_in(window, |this, window, cx| { - this.set_querying(false, window, cx); + this.set_searching(false, window, cx); }); }); } @@ -349,26 +299,27 @@ where } } - fn set_querying(&mut self, querying: bool, _window: &mut Window, cx: &mut Context) { - self.querying = querying; - if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(querying, cx)) - } - cx.notify(); + fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context) { + self.query_input + .update(cx, |input, cx| input.set_loading(searching, cx)); } - /// Dispatch delegate's `load_more` method when the visible range is near the end. + /// Dispatch delegate's `load_more` method when the + /// visible range is near the end. fn load_more_if_need( &mut self, - items_count: usize, + entities_count: usize, visible_end: usize, window: &mut Window, cx: &mut Context, ) { + // FIXME: Here need void sections items count. + let threshold = self.delegate.load_more_threshold(); - // Securely handle subtract logic to prevent attempt to subtract with overflow - if visible_end >= items_count.saturating_sub(threshold) { - if !self.delegate.can_load_more(cx) { + // Securely handle subtract logic to prevent attempt + // to subtract with overflow + if visible_end >= entities_count.saturating_sub(threshold) { + if !self.delegate.has_more(cx) { return; } @@ -380,18 +331,16 @@ where } } + #[allow(dead_code)] pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self { self.reset_on_cancel = reset; self } fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if self.selected_index.is_none() { - cx.propagate(); - } - + cx.propagate(); if self.reset_on_cancel { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self.delegate.cancel(window, cx); @@ -405,7 +354,7 @@ where window: &mut Window, cx: &mut Context, ) { - if self.delegate.items_count(cx) == 0 { + if self.rows_cache.len() == 0 { return; } @@ -420,7 +369,11 @@ where cx.notify(); } - fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context) { + if !self.selectable { + return; + } + self.selected_index = Some(ix); self.delegate.set_selected_index(Some(ix), window, cx); self.scroll_to_selected_item(window, cx); @@ -428,222 +381,365 @@ where cx.notify(); } - fn on_select_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_prev( + &mut self, + _: &SelectUp, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let mut selected_index = self.selected_index.unwrap_or(0); - if selected_index > 0 { - selected_index -= 1; - } else { - selected_index = items_count - 1; - } - self.select_item(selected_index, window, cx); + let prev_ix = self.rows_cache.prev(self.selected_index); + self.select_item(prev_ix, window, cx); } - fn on_select_next(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_next( + &mut self, + _: &SelectDown, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let selected_index; - if let Some(ix) = self.selected_index { - if ix < items_count - 1 { - selected_index = ix + 1; - } else { - // When the last item is selected, select the first item. - selected_index = 0; - } - } else { - // When no selected index, select the first item. - selected_index = 0; + let next_ix = self.rows_cache.next(self.selected_index); + self.select_item(next_ix, window, cx); + } + + fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context) { + let sections_count = self.delegate.sections_count(cx).max(1); + let mut measured_size = MeasuredEntrySize::default(); + + // Measure the item_height and section header/footer height. + let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); + measured_size.item_size = self + .render_list_item(self.item_to_measure_index, window, cx) + .into_any_element() + .layout_as_root(available_space, window, cx); + + if let Some(mut el) = self + .delegate + .render_section_header(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_header_size = el.layout_as_root(available_space, window, cx); + } + if let Some(mut el) = self + .delegate + .render_section_footer(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_footer_size = el.layout_as_root(available_space, window, cx); } - self.select_item(selected_index, window, cx); + self.rows_cache + .prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| { + self.delegate.items_count(section_ix, cx) + }); } fn render_list_item( &mut self, - ix: usize, + ix: IndexPath, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let selected = self.selected_index == Some(ix); - let right_clicked = self.right_clicked_index == Some(ix); + let selectable = self.selectable; + let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false); + let mouse_right_clicked = self + .mouse_right_clicked_index + .map(|s| s.eq_row(ix)) + .unwrap_or(false); + let id = SharedString::from(format!("list-item-{}", ix)); div() - .id("list-item") + .id(id) .w_full() .relative() - .children(self.delegate.render_item(ix, window, cx)) - .when(self.selectable, |this| { - this.when(selected || right_clicked, |this| { - this.child( - div() - .absolute() - .top(px(0.)) - .left(px(0.)) - .right(px(0.)) - .bottom(px(0.)) - .when(selected, |this| this.bg(cx.theme().element_background)) - .border_1() - .border_color(cx.theme().border_selected), - ) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, ev: &MouseDownEvent, window, cx| { - this.right_clicked_index = None; - this.selected_index = Some(ix); - this.on_action_confirm( - &Confirm { - secondary: ev.modifiers.secondary(), - }, - window, - cx, - ); - }), - ) + .overflow_hidden() + .children(self.delegate.render_item(ix, window, cx).map(|item| { + item.selected(selected) + .secondary_selected(mouse_right_clicked) + })) + .when(selectable, |this| { + this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { + this.set_right_clicked_index(None, window, cx); + this.selected_index = Some(ix); + this.on_action_confirm( + &Confirm { + secondary: e.modifiers().secondary(), + }, + window, + cx, + ); + })) .on_mouse_down( MouseButton::Right, - cx.listener(move |this, _, _, cx| { - this.right_clicked_index = Some(ix); + cx.listener(move |this, _, window, cx| { + this.set_right_clicked_index(Some(ix), window, cx); cx.notify(); }), ) }) } + + fn render_items( + &mut self, + items_count: usize, + entities_count: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let rows_cache = self.rows_cache.clone(); + let scrollbar_visible = self.options.scrollbar_visible; + let scroll_handle = self.scroll_handle.clone(); + + v_flex() + .flex_grow() + .relative() + .size_full() + .when_some(self.options.max_height, |this, h| this.max_h(h)) + .overflow_hidden() + .when(items_count == 0, |this| { + this.child(self.delegate.render_empty(window, cx)) + }) + .when(items_count > 0, { + |this| { + this.child( + uniform_list( + "virtual-list", + rows_cache.items_count(), + cx.processor(move |this, range: Range, window, cx| { + this.load_more_if_need(entities_count, range.end, window, cx); + + // NOTE: Here the v_virtual_list would not able to have gap_y, + // because the section header, footer is always have rendered as a empty child item, + // even the delegate give a None result. + + range + .map(|ix| { + let Some(entry) = rows_cache.get(ix) else { + return div(); + }; + + div().children(match entry { + RowEntry::Entry(index) => Some( + this.render_list_item(index, window, cx) + .into_any_element(), + ), + RowEntry::SectionHeader(section_ix) => this + .delegate_mut() + .render_section_header(section_ix, window, cx) + .map(|r| r.into_any_element()), + RowEntry::SectionFooter(section_ix) => this + .delegate_mut() + .render_section_footer(section_ix, window, cx) + .map(|r| r.into_any_element()), + }) + }) + .collect::>() + }), + ) + .when(self.options.max_height.is_some(), |this| { + this.with_sizing_behavior(ListSizingBehavior::Infer) + }) + .track_scroll(&scroll_handle) + .into_any_element(), + ) + } + }) + .when(scrollbar_visible, |this| { + this.child(Scrollbar::vertical(&scroll_handle)) + }) + } } -impl Focusable for List +impl Focusable for ListState where D: ListDelegate, { fn focus_handle(&self, cx: &App) -> FocusHandle { - if let Some(query_input) = &self.query_input { - query_input.focus_handle(cx) + if self.searchable { + self.query_input.focus_handle(cx) } else { self.focus_handle.clone() } } } -impl EventEmitter for List where D: ListDelegate {} -impl Render for List +impl EventEmitter for ListState where D: ListDelegate {} +impl Render for ListState where D: ListDelegate, { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let vertical_scroll_handle = self.vertical_scroll_handle.clone(); - let items_count = self.delegate.items_count(cx); - let loading = self.delegate.loading(cx); - let sizing_behavior = if self.max_height.is_some() { - ListSizingBehavior::Infer + self.prepare_items_if_needed(window, cx); + + // Scroll to the selected item if it is set. + if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() { + if let Some(item_ix) = self.rows_cache.position_of(&ix) { + self.scroll_handle.scroll_to_item(item_ix, strategy); + } + } + + let loading = self.delegate().loading(cx); + let query_input = if self.searchable { + // sync placeholder + if let Some(placeholder) = &self.options.search_placeholder { + self.query_input.update(cx, |input, cx| { + input.set_placeholder(placeholder.clone(), window, cx); + }); + } + Some(self.query_input.clone()) } else { - ListSizingBehavior::Auto + None }; - let initial_view = if let Some(input) = &self.query_input { + let loading_view = if loading { + Some(self.delegate.render_loading(window, cx).into_any_element()) + } else { + None + }; + let initial_view = if let Some(input) = &query_input { if input.read(cx).value().is_empty() { - self.delegate().render_initial(window, cx) + self.delegate.render_initial(window, cx) } else { None } } else { None }; + let items_count = self.rows_cache.items_count(); + let entities_count = self.rows_cache.len(); + let mouse_right_clicked_index = self.mouse_right_clicked_index; v_flex() .key_context("List") - .id("list") + .id("list-state") .track_focus(&self.focus_handle) .size_full() .relative() .overflow_hidden() - .when_some(self.query_input.clone(), |this, input| { + .when_some(query_input, |this, input| { this.child( div() - .map(|this| match self.size { - Size::Small => this.py_0().px_1p5(), - _ => this.py_1().px_2(), + .map(|this| match self.options.size { + Size::Small => this.px_1p5(), + _ => this.px_2(), }) .border_b_1() .border_color(cx.theme().border) .child( TextInput::new(&input) - .with_size(self.size) + .with_size(self.options.size) + .appearance(false) + .cleanable() + .p_0() .prefix( Icon::new(IconName::Search).text_color(cx.theme().text_muted), - ) - .cleanable() - .appearance(false), + ), ), ) }) - .when(loading, |this| { - this.child(self.delegate().render_loading(window, cx)) - }) .when(!loading, |this| { this.on_action(cx.listener(Self::on_action_cancel)) .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_select_next)) - .on_action(cx.listener(Self::on_select_prev)) + .on_action(cx.listener(Self::on_action_select_next)) + .on_action(cx.listener(Self::on_action_select_prev)) .map(|this| { if let Some(view) = initial_view { this.child(view) } else { - this.child( - v_flex() - .flex_grow() - .relative() - .when_some(self.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate().render_empty(window, cx)) - }) - .when(items_count > 0, |this| { - this.child( - uniform_list( - "list", - items_count, - cx.processor( - move |list, range: Range, window, cx| { - list.load_more_if_need( - items_count, - range.end, - window, - cx, - ); - - range - .map(|ix| { - list.render_list_item( - ix, window, cx, - ) - }) - .collect::>() - }, - ), - ) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(&vertical_scroll_handle) - .into_any_element(), - ) - }) - .children(self.render_scrollbar(window, cx)), - ) + this.child(self.render_items(items_count, entities_count, window, cx)) } }) // Click out to cancel right clicked row - .when(self.right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.right_clicked_index = None; + .when(mouse_right_clicked_index.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, window, cx| { + this.set_right_clicked_index(None, window, cx); cx.notify(); })) }) }) + .children(loading_view) + } +} + +/// The List element. +#[derive(IntoElement)] +pub struct List { + state: Entity>, + style: StyleRefinement, + options: ListOptions, +} + +impl List +where + D: ListDelegate + 'static, +{ + /// Create a new List element with the given ListState entity. + pub fn new(state: &Entity>) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + options: ListOptions::default(), + } + } + + /// Set whether the scrollbar is visible, default is `true`. + pub fn scrollbar_visible(mut self, visible: bool) -> Self { + self.options.scrollbar_visible = visible; + self + } + + /// Sets the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } +} + +impl Styled for List +where + D: ListDelegate + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for List +where + D: ListDelegate + 'static, +{ + fn with_size(mut self, size: impl Into) -> Self { + self.options.size = size.into(); + self + } +} + +impl RenderOnce for List +where + D: ListDelegate + 'static, +{ + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + // Take paddings, max_height to options, and clear them from style, + // because they would be applied to the inner virtual list. + self.options.paddings = self.style.padding.clone(); + self.options.max_height = self.style.max_size.height; + self.style.padding = EdgesRefinement::default(); + self.style.max_size.height = None; + + self.state.update(cx, |state, _| { + state.options = self.options; + }); + + div() + .id("list") + .size_full() + .refine_style(&self.style) + .child(self.state.clone()) } } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index 6a14edb..d2d872a 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,39 +1,57 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, - MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, - Window, + div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, + MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, }; use smallvec::SmallVec; use theme::ActiveTheme; -use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable as _}; +use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt}; -type OnClick = Option>; -type OnMouseEnter = Option>; -type Suffix = Option AnyElement + 'static>>; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ListItemMode { + #[default] + Entry, + Separator, +} + +impl ListItemMode { + #[inline] + fn is_separator(&self) -> bool { + matches!(self, ListItemMode::Separator) + } +} #[derive(IntoElement)] pub struct ListItem { base: Stateful
, + mode: ListItemMode, + style: StyleRefinement, disabled: bool, selected: bool, + secondary_selected: bool, confirmed: bool, check_icon: Option, - on_click: OnClick, - on_mouse_enter: OnMouseEnter, - suffix: Suffix, + #[allow(clippy::type_complexity)] + on_click: Option>, + #[allow(clippy::type_complexity)] + on_mouse_enter: Option>, + #[allow(clippy::type_complexity)] + suffix: Option AnyElement + 'static>>, children: SmallVec<[AnyElement; 2]>, } impl ListItem { pub fn new(id: impl Into) -> Self { let id: ElementId = id.into(); - Self { - base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(), + mode: ListItemMode::Entry, + base: h_flex().id(id), + style: StyleRefinement::default(), disabled: false, selected: false, + secondary_selected: false, confirmed: false, on_click: None, on_mouse_enter: None, @@ -43,9 +61,15 @@ impl ListItem { } } + /// Set this list item to as a separator, it not able to be selected. + pub fn separator(mut self) -> Self { + self.mode = ListItemMode::Separator; + self + } + /// Set to show check icon, default is None. - pub fn check_icon(mut self, icon: IconName) -> Self { - self.check_icon = Some(Icon::new(icon)); + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.check_icon = Some(icon.into()); self } @@ -111,11 +135,16 @@ impl Selectable for ListItem { fn is_selected(&self) -> bool { self.selected } + + fn secondary_selected(mut self, selected: bool) -> Self { + self.secondary_selected = selected; + self + } } impl Styled for ListItem { fn style(&mut self) -> &mut gpui::StyleRefinement { - self.base.style() + &mut self.style } } @@ -127,35 +156,39 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_active = self.selected || self.confirmed; + let is_active = self.confirmed || self.selected; + + let corner_radii = self.style.corner_radii.clone(); + + let _selected_style = StyleRefinement { + corner_radii, + ..Default::default() + }; + + let is_selectable = !(self.disabled || self.mode.is_separator()); self.base + .relative() + .gap_x_1() + .py_1() + .px_3() + .text_base() .text_color(cx.theme().text) .relative() .items_center() .justify_between() - .when_some(self.on_click, |this, on_click| { - if !self.disabled { - this.cursor_pointer() - .on_mouse_down(MouseButton::Left, move |_, _window, cx| { - cx.stop_propagation(); - }) - .on_click(on_click) - } else { - this - } + .refine_style(&self.style) + .when(is_selectable, |this| { + this.when_some(self.on_click, |this, on_click| this.on_click(on_click)) + .when_some(self.on_mouse_enter, |this, on_mouse_enter| { + this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) + }) + .when(!is_active, |this| { + this.hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) }) - .when(is_active, |this| this.bg(cx.theme().element_active)) - .when(!is_active && !self.disabled, |this| { - this.hover(|this| this.bg(cx.theme().elevated_surface_background)) - }) - // Mouse enter - .when_some(self.on_mouse_enter, |this, on_mouse_enter| { - if !self.disabled { - this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) - } else { - this - } + .when(!is_selectable, |this| { + this.text_color(cx.theme().text_muted) }) .child( h_flex() @@ -177,5 +210,17 @@ impl RenderOnce for ListItem { }), ) .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx))) + .map(|this| { + if is_selectable && (self.selected || self.secondary_selected) { + let bg = if self.selected { + cx.theme().ghost_element_active + } else { + cx.theme().ghost_element_background + }; + this.bg(bg) + } else { + this + } + }) } } diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs index 8c3fa21..9ad64d0 100644 --- a/crates/ui/src/list/loading.rs +++ b/crates/ui/src/list/loading.rs @@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem { .gap_1p5() .overflow_hidden() .child(Skeleton::new().h_5().w_48().max_w_full()) - .child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()), + .child(Skeleton::new().secondary().h_3().w_64().max_w_full()), ) } } diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index 88baf0f..11105c1 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,7 +1,28 @@ +pub(crate) mod cache; +mod delegate; #[allow(clippy::module_inception)] mod list; mod list_item; mod loading; +mod separator_item; +pub use delegate::*; pub use list::*; pub use list_item::*; +pub use separator_item::*; +use serde::{Deserialize, Serialize}; + +/// Settings for List. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListSettings { + /// Whether to use active highlight style on ListItem, default + pub active_highlight: bool, +} + +impl Default for ListSettings { + fn default() -> Self { + Self { + active_highlight: true, + } + } +} diff --git a/crates/ui/src/list/separator_item.rs b/crates/ui/src/list/separator_item.rs new file mode 100644 index 0000000..b419a4e --- /dev/null +++ b/crates/ui/src/list/separator_item.rs @@ -0,0 +1,50 @@ +use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement}; +use smallvec::SmallVec; + +use crate::list::ListItem; +use crate::{Selectable, StyledExt}; + +pub struct ListSeparatorItem { + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl ListSeparatorItem { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl Default for ListSeparatorItem { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for ListSeparatorItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Selectable for ListSeparatorItem { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } +} + +impl RenderOnce for ListSeparatorItem { + fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement { + ListItem::new("separator") + .refine_style(&self.style) + .children(self.children) + .disabled(true) + } +} diff --git a/crates/ui/src/menu/app_menu_bar.rs b/crates/ui/src/menu/app_menu_bar.rs index 9548a49..ea90217 100644 --- a/crates/ui/src/menu/app_menu_bar.rs +++ b/crates/ui/src/menu/app_menu_bar.rs @@ -1,16 +1,17 @@ use gpui::prelude::FluentBuilder; use gpui::{ anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, - Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Window, + Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::button::{Button, ButtonVariants}; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; use crate::{h_flex, Selectable, Sizable}; const CONTEXT: &str = "AppMenuBar"; + pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), @@ -22,67 +23,74 @@ pub fn init(cx: &mut App) { /// The application menu bar, for Windows and Linux. pub struct AppMenuBar { menus: Vec>, - selected_ix: Option, + selected_index: Option, } impl AppMenuBar { /// Create a new app menu bar. - pub fn new(window: &mut Window, cx: &mut App) -> Entity { + pub fn new(cx: &mut App) -> Entity { cx.new(|cx| { - let menu_bar = cx.entity(); - let menus = cx - .get_menus() - .unwrap_or_default() - .iter() - .enumerate() - .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx)) - .collect(); - - Self { - selected_ix: None, - menus, - } + let mut this = Self { + selected_index: None, + menus: Vec::new(), + }; + this.reload(cx); + this }) } - fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + /// Reload the menus from the app. + pub fn reload(&mut self, cx: &mut Context) { + let menu_bar = cx.entity(); + self.menus = cx + .get_menus() + .unwrap_or_default() + .iter() + .enumerate() + .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx)) + .collect(); + self.selected_index = None; + cx.notify(); + } + + fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix == 0 { + let new_ix = if selected_index == 0 { self.menus.len().saturating_sub(1) } else { - selected_ix.saturating_sub(1) + selected_index.saturating_sub(1) }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix + 1 >= self.menus.len() { + let new_ix = if selected_index + 1 >= self.menus.len() { 0 } else { - selected_ix + 1 + selected_index + 1 }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.set_selected_ix(None, window, cx); + fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.set_selected_index(None, window, cx); } - fn set_selected_ix(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { - self.selected_ix = ix; + fn set_selected_index(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { + self.selected_index = ix; cx.notify(); } #[inline] fn has_activated_menu(&self) -> bool { - self.selected_ix.is_some() + self.selected_index.is_some() } } @@ -91,9 +99,9 @@ impl Render for AppMenuBar { h_flex() .id("app-menu-bar") .key_context(CONTEXT) - .on_action(cx.listener(Self::move_left)) - .on_action(cx.listener(Self::move_right)) - .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::on_move_left)) + .on_action(cx.listener(Self::on_move_right)) + .on_action(cx.listener(Self::on_cancel)) .size_full() .gap_x_1() .overflow_x_scroll() @@ -117,7 +125,6 @@ impl AppMenu { ix: usize, menu: &OwnedMenu, menu_bar: Entity, - _: &mut Window, cx: &mut App, ) -> Entity { let name = menu.name.clone(); @@ -173,7 +180,7 @@ impl AppMenu { self._subscription.take(); self.popup_menu.take(); self.menu_bar.update(cx, |state, cx| { - state.cancel(&Cancel, window, cx); + state.on_cancel(&Cancel, window, cx); }); } @@ -183,11 +190,11 @@ impl AppMenu { window: &mut Window, cx: &mut Context, ) { - let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix); + let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix); self.menu_bar.update(cx, |state, cx| { let new_ix = if is_selected { None } else { Some(self.ix) }; - state.set_selected_ix(new_ix, window, cx); + state.set_selected_index(new_ix, window, cx); }); } @@ -202,7 +209,7 @@ impl AppMenu { } self.menu_bar.update(cx, |state, cx| { - state.set_selected_ix(Some(self.ix), window, cx); + state.set_selected_index(Some(self.ix), window, cx); }); } } @@ -210,7 +217,7 @@ impl AppMenu { impl Render for AppMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let menu_bar = self.menu_bar.read(cx); - let is_selected = menu_bar.selected_ix == Some(self.ix); + let is_selected = menu_bar.selected_index == Some(self.ix); div() .id(self.ix) @@ -219,10 +226,15 @@ impl Render for AppMenu { Button::new("menu") .small() .py_0p5() - .xsmall() + .compact() .ghost() .label(self.name.clone()) .selected(is_selected) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + // Stop propagation to avoid dragging the window. + window.prevent_default(); + cx.stop_propagation(); + }) .on_click(cx.listener(Self::handle_trigger_click)), ) .on_hover(cx.listener(Self::handle_hover)) diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 2984adc..679c4c6 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -3,49 +3,66 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element, - ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful, - Style, Subscription, Window, + anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element, + ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, + StyleRefinement, Styled, Subscription, Window, }; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; -pub trait ContextMenuExt: ParentElement + Sized { +/// A extension trait for adding a context menu to an element. +pub trait ContextMenuExt: ParentElement + Styled { + /// Add a context menu to the element. + /// + /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element. + /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element. fn context_menu( self, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.child(ContextMenu::new("context-menu").menu(f)) + ) -> ContextMenu + where + Self: Sized, + { + // Generate a unique ID based on the element's memory address to ensure + // each context menu has its own state and doesn't share with others + let id = format!("context-menu-{:p}", &self as *const _); + ContextMenu::new(id, self).menu(f) } } -impl ContextMenuExt for Stateful where E: ParentElement {} +impl ContextMenuExt for E {} /// A context menu that can be shown on right-click. -#[allow(clippy::type_complexity)] -pub struct ContextMenu { +pub struct ContextMenu { id: ElementId, - menu: - Option) -> PopupMenu + 'static>>, + element: Option, + #[allow(clippy::type_complexity)] + menu: Option) -> PopupMenu>>, + // This is not in use, just for style refinement forwarding. + _ignore_style: StyleRefinement, anchor: Corner, } -impl ContextMenu { - pub fn new(id: impl Into) -> Self { +impl ContextMenu { + /// Create a new context menu with the given ID. + pub fn new(id: impl Into, element: E) -> Self { Self { id: id.into(), + element: Some(element), menu: None, anchor: Corner::TopLeft, + _ignore_style: StyleRefinement::default(), } } + /// Build the context menu using the given builder function. #[must_use] - pub fn menu(mut self, builder: F) -> Self + fn menu(mut self, builder: F) -> Self where F: Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, { - self.menu = Some(Box::new(builder)); + self.menu = Some(Rc::new(builder)); self } @@ -67,7 +84,25 @@ impl ContextMenu { } } -impl IntoElement for ContextMenu { +impl ParentElement for ContextMenu { + fn extend(&mut self, elements: impl IntoIterator) { + if let Some(element) = &mut self.element { + element.extend(elements); + } + } +} + +impl Styled for ContextMenu { + fn style(&mut self) -> &mut StyleRefinement { + if let Some(element) = &mut self.element { + element.style() + } else { + &mut self._ignore_style + } + } +} + +impl IntoElement for ContextMenu { type Element = Self; fn into_element(self) -> Self::Element { @@ -83,14 +118,14 @@ struct ContextMenuSharedState { } pub struct ContextMenuState { - menu_element: Option, + element: Option, shared_state: Rc>, } impl Default for ContextMenuState { fn default() -> Self { Self { - menu_element: None, + element: None, shared_state: Rc::new(RefCell::new(ContextMenuSharedState { menu_view: None, open: false, @@ -101,8 +136,8 @@ impl Default for ContextMenuState { } } -impl Element for ContextMenu { - type PrepaintState = (); +impl Element for ContextMenu { + type PrepaintState = Hitbox; type RequestLayoutState = ContextMenuState; fn id(&self) -> Option { @@ -113,7 +148,6 @@ impl Element for ContextMenu { None } - #[allow(clippy::field_reassign_with_default)] fn request_layout( &mut self, id: Option<&gpui::GlobalElementId>, @@ -121,71 +155,73 @@ impl Element for ContextMenu { window: &mut Window, cx: &mut App, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - // Set the layout style relative to the table view to get same size. - style.position = Position::Absolute; - style.flex_grow = 1.0; - style.flex_shrink = 1.0; - style.size.width = relative(1.).into(); - style.size.height = relative(1.).into(); - let anchor = self.anchor; self.with_element_state( id.unwrap(), window, cx, - |_, state: &mut ContextMenuState, window, cx| { + |this, state: &mut ContextMenuState, window, cx| { let (position, open) = { let shared_state = state.shared_state.borrow(); (shared_state.position, shared_state.open) }; let menu_view = state.shared_state.borrow().menu_view.clone(); - let (menu_element, menu_layout_id) = if open { + let mut menu_element = None; + if open { let has_menu_item = menu_view .as_ref() .map(|menu| !menu.read(cx).is_empty()) .unwrap_or(false); if has_menu_item { - let mut menu_element = deferred( - anchored() - .position(position) - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. - if !menu.focus_handle(cx).contains_focused(window, cx) { - menu.focus_handle(cx).focus(window, cx); - } + menu_element = Some( + deferred( + anchored().child( + div() + .w(window.bounds().size.width) + .h(window.bounds().size.height) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .when_some(menu_view, |this, menu| { + // Focus the menu, so that can be handle the action. + if !menu + .focus_handle(cx) + .contains_focused(window, cx) + { + menu.focus_handle(cx).focus(window, cx); + } - this.child(div().occlude().child(menu.clone())) - }), - ) - .with_priority(1) - .into_any(); - - let menu_layout_id = menu_element.request_layout(window, cx); - (Some(menu_element), Some(menu_layout_id)) - } else { - (None, None) + this.child(menu.clone()) + }), + ), + ), + ) + .with_priority(1) + .into_any(), + ); } - } else { - (None, None) - }; - - let mut layout_ids = vec![]; - if let Some(menu_layout_id) = menu_layout_id { - layout_ids.push(menu_layout_id); } - let layout_id = window.request_layout(style, layout_ids, cx); + let mut element = this + .element + .take() + .expect("Element should exists.") + .children(menu_element) + .into_any_element(); + + let layout_id = element.request_layout(window, cx); ( layout_id, ContextMenuState { - menu_element, - + element: Some(element), ..Default::default() }, ) @@ -197,33 +233,33 @@ impl Element for ContextMenu { &mut self, _: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - _: gpui::Bounds, + bounds: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.prepaint(window, cx); + if let Some(element) = &mut request_layout.element { + element.prepaint(window, cx); } + window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, id: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - bounds: gpui::Bounds, + _: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, + hitbox: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.paint(window, cx); + if let Some(element) = &mut request_layout.element { + element.paint(window, cx); } - let Some(builder) = self.menu.take() else { - return; - }; + // Take the builder before setting up element state to avoid borrow issues + let builder = self.menu.clone(); self.with_element_state( id.unwrap(), @@ -232,33 +268,53 @@ impl Element for ContextMenu { |_view, state: &mut ContextMenuState, window, _| { let shared_state = state.shared_state.clone(); + let hitbox = hitbox.clone(); // When right mouse click, to build content menu, and show it at the mouse position. window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Right - && bounds.contains(&event.position) + && hitbox.is_hovered(window) { { let mut shared_state = shared_state.borrow_mut(); + // Clear any existing menu view to allow immediate replacement + // Set the new position and open the menu + shared_state.menu_view = None; + shared_state._subscription = None; shared_state.position = event.position; shared_state.open = true; } - let menu = PopupMenu::build(window, cx, |menu, window, cx| { - (builder)(menu, window, cx) - }); - - let _subscription = window.subscribe(&menu, cx, { + // Use defer to build the menu in the next frame, avoiding race conditions + window.defer(cx, { let shared_state = shared_state.clone(); - move |_, _: &DismissEvent, window, _| { - shared_state.borrow_mut().open = false; - window.refresh(); + let builder = builder.clone(); + move |window, cx| { + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + let Some(build) = &builder else { + return menu; + }; + build(menu, window, cx) + }); + + // Set up the subscription for dismiss handling + let _subscription = window.subscribe(&menu, cx, { + let shared_state = shared_state.clone(); + move |_, _: &DismissEvent, window, _cx| { + shared_state.borrow_mut().open = false; + window.refresh(); + } + }); + + // Update the shared state with the built menu and subscription + { + let mut state = shared_state.borrow_mut(); + state.menu_view = Some(menu.clone()); + state._subscription = Some(_subscription); + window.refresh(); + } } }); - - shared_state.borrow_mut().menu_view = Some(menu.clone()); - shared_state.borrow_mut()._subscription = Some(_subscription); - window.refresh(); } }); }, diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs new file mode 100644 index 0000000..c5938cc --- /dev/null +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -0,0 +1,142 @@ +use std::rc::Rc; + +use gpui::{ + Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, + RenderOnce, SharedString, StyleRefinement, Styled, Window, +}; + +use crate::button::Button; +use crate::menu::PopupMenu; +use crate::popover::Popover; +use crate::Selectable; + +/// A dropdown menu trait for buttons and other interactive elements +pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { + /// Create a dropdown menu with the given items, anchored to the TopLeft corner + fn dropdown_menu( + self, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + self.dropdown_menu_with_anchor(Corner::TopLeft, f) + } + + /// Create a dropdown menu with the given items, anchored to the given corner + fn dropdown_menu_with_anchor( + mut self, + anchor: impl Into, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + let style = self.style().clone(); + let id = self.interactivity().element_id.clone(); + + DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style) + } +} + +impl DropdownMenu for Button {} + +#[derive(IntoElement)] +pub struct DropdownMenuPopover { + id: ElementId, + style: StyleRefinement, + anchor: Corner, + trigger: T, + #[allow(clippy::type_complexity)] + builder: Rc) -> PopupMenu>, +} + +impl DropdownMenuPopover +where + T: Selectable + IntoElement + 'static, +{ + fn new( + id: ElementId, + anchor: impl Into, + trigger: T, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + Self { + id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(), + style: StyleRefinement::default(), + anchor: anchor.into(), + trigger, + builder: Rc::new(builder), + } + } + + /// Set the anchor corner for the dropdown menu popover. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + /// Set the style refinement for the dropdown menu trigger. + fn trigger_style(mut self, style: StyleRefinement) -> Self { + self.style = style; + self + } +} + +#[derive(Default)] +struct DropdownMenuState { + menu: Option>, +} + +impl RenderOnce for DropdownMenuPopover +where + T: Selectable + IntoElement + 'static, +{ + fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { + let builder = self.builder.clone(); + let menu_state = + window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default()); + + Popover::new(SharedString::from(format!("popover:{}", self.id))) + .appearance(false) + .overlay_closable(false) + .trigger(self.trigger) + .trigger_style(self.style) + .anchor(self.anchor) + .content(move |_, window, cx| { + // Here is special logic to only create the PopupMenu once and reuse it. + // Because this `content` will called in every time render, so we need to store the menu + // in state to avoid recreating at every render. + // + // And we also need to rebuild the menu when it is dismissed, to rebuild menu items + // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below. + let menu = match menu_state.read(cx).menu.clone() { + Some(menu) => menu, + None => { + let builder = builder.clone(); + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state.update(cx, |state, _| { + state.menu = Some(menu.clone()); + }); + menu.focus_handle(cx).focus(window, cx); + + // Listen for dismiss events from the PopupMenu to close the popover. + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu.clone() + } + }; + + menu.clone() + }) + } +} diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 95f2b7f..cb905d1 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -10,20 +10,22 @@ use theme::ActiveTheme; use crate::{h_flex, Disableable, StyledExt}; #[derive(IntoElement)] -#[allow(clippy::type_complexity)] pub(crate) struct MenuItemElement { id: ElementId, group_name: SharedString, style: StyleRefinement, disabled: bool, selected: bool, + #[allow(clippy::type_complexity)] on_click: Option>, + #[allow(clippy::type_complexity)] on_hover: Option>, children: SmallVec<[AnyElement; 2]>, } impl MenuItemElement { - pub fn new(id: impl Into, group_name: impl Into) -> Self { + /// Create a new MenuItem with the given ID and group name. + pub(crate) fn new(id: impl Into, group_name: impl Into) -> Self { let id: ElementId = id.into(); Self { id: id.clone(), @@ -38,17 +40,19 @@ impl MenuItemElement { } /// Set ListItem as the selected item style. - pub fn selected(mut self, selected: bool) -> Self { + pub(crate) fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } - pub fn disabled(mut self, disabled: bool) -> Self { + /// Set the disabled state of the MenuItem. + pub(crate) fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } - pub fn on_click( + /// Set a handler for when the MenuItem is clicked. + pub(crate) fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { @@ -88,7 +92,7 @@ impl RenderOnce for MenuItemElement { h_flex() .id(self.id) .group(&self.group_name) - .gap_x_2() + .gap_x_1() .py_1() .px_2() .text_base() @@ -102,12 +106,12 @@ impl RenderOnce for MenuItemElement { }) .when(!self.disabled, |this| { this.group_hover(self.group_name, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) }) .when(self.selected, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) }) .when_some(self.on_click, |this, on_click| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| { diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index 0d91c7f..3152a15 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -1,12 +1,15 @@ use gpui::App; mod app_menu_bar; +mod context_menu; +mod dropdown_menu; mod menu_item; - -pub mod context_menu; -pub mod popup_menu; +mod popup_menu; pub use app_menu_bar::AppMenuBar; +pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; +pub use dropdown_menu::DropdownMenu; +pub use popup_menu::{PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { app_menu_bar::init(cx); diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index b2987a7..a092eab 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -2,20 +2,19 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, Corner, - DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, - IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Render, - ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, - Window, + anchored, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, + Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, + InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, + Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, }; use theme::ActiveTheme; use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}; -use crate::button::Button; +use crate::kbd::Kbd; use crate::menu::menu_item::MenuItemElement; -use crate::popover::Popover; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{h_flex, v_flex, Icon, IconName, Kbd, Selectable, Side, Sizable as _, Size, StyledExt}; +use crate::scroll::ScrollableElement; +use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt}; const CONTEXT: &str = "PopupMenu"; @@ -30,57 +29,38 @@ pub fn init(cx: &mut App) { ]); } -pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static { - /// Create a popup menu with the given items, anchored to the TopLeft corner - fn popup_menu( - self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - self.popup_menu_with_anchor(Corner::TopLeft, f) - } - - /// Create a popup menu with the given items, anchored to the given corner - fn popup_menu_with_anchor( - mut self, - anchor: impl Into, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - let style = self.style().clone(); - let id = self.interactivity().element_id.clone(); - - Popover::new(SharedString::from(format!("popup-menu:{id:?}"))) - .no_style() - .trigger(self) - .trigger_style(style) - .anchor(anchor.into()) - .content(move |window, cx| { - PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx)) - }) - } -} - -impl PopupMenuExt for Button {} - -#[allow(clippy::type_complexity)] -pub(crate) enum PopupMenuItem { +/// An menu item in a popup menu. +pub enum PopupMenuItem { + /// A menu separator item. Separator, + /// A non-interactive label item. Label(SharedString), + /// A standard menu item. Item { icon: Option, label: SharedString, disabled: bool, + checked: bool, is_link: bool, action: Option>, // For link item - handler: Option>, + #[allow(clippy::type_complexity)] + handler: Option>, }, + /// A menu item with custom element render. ElementItem { icon: Option, disabled: bool, - action: Box, + checked: bool, + action: Option>, + #[allow(clippy::type_complexity)] render: Box AnyElement + 'static>, - handler: Option>, + #[allow(clippy::type_complexity)] + handler: Option>, }, + /// A submenu item that opens another popup menu. + /// + /// NOTE: This is only supported when the parent menu is not `scrollable`. Submenu { icon: Option, label: SharedString, @@ -89,7 +69,166 @@ pub(crate) enum PopupMenuItem { }, } +impl FluentBuilder for PopupMenuItem {} impl PopupMenuItem { + /// Create a new menu item with the given label. + #[inline] + pub fn new(label: impl Into) -> Self { + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: false, + handler: None, + } + } + + /// Create a new menu item with custom element render. + #[inline] + pub fn element(builder: F) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + PopupMenuItem::ElementItem { + icon: None, + disabled: false, + checked: false, + action: None, + render: Box::new(move |window, cx| builder(window, cx).into_any_element()), + handler: None, + } + } + + /// Create a new submenu item that opens another popup menu. + #[inline] + pub fn submenu(label: impl Into, menu: Entity) -> Self { + PopupMenuItem::Submenu { + icon: None, + label: label.into(), + disabled: false, + menu, + } + } + + /// Create a separator menu item. + #[inline] + pub fn separator() -> Self { + PopupMenuItem::Separator + } + + /// Creates a label menu item. + #[inline] + pub fn label(label: impl Into) -> Self { + PopupMenuItem::Label(label.into()) + } + + /// Set the icon for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn icon(mut self, icon: impl Into) -> Self { + match &mut self { + PopupMenuItem::Item { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::ElementItem { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::Submenu { icon: i, .. } => { + *i = Some(icon.into()); + } + _ => {} + } + self + } + + /// Set the action for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn action(mut self, action: Box) -> Self { + match &mut self { + PopupMenuItem::Item { action: a, .. } => { + *a = Some(action); + } + PopupMenuItem::ElementItem { action: a, .. } => { + *a = Some(action); + } + _ => {} + } + self + } + + /// Set the disabled state for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn disabled(mut self, disabled: bool) -> Self { + match &mut self { + PopupMenuItem::Item { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::ElementItem { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::Submenu { disabled: d, .. } => { + *d = disabled; + } + _ => {} + } + self + } + + /// Set checked state for the menu item. + /// + /// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon. + pub fn checked(mut self, checked: bool) -> Self { + match &mut self { + PopupMenuItem::Item { checked: c, .. } => { + *c = checked; + } + PopupMenuItem::ElementItem { checked: c, .. } => { + *c = checked; + } + _ => {} + } + self + } + + /// Add a click handler for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, + { + match &mut self { + PopupMenuItem::Item { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + PopupMenuItem::ElementItem { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + _ => {} + } + self + } + + /// Create a link menu item. + #[inline] + pub fn link(label: impl Into, href: impl Into) -> Self { + let href = href.into(); + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: true, + handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))), + } + } + #[inline] fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) @@ -112,28 +251,53 @@ impl PopupMenuItem { fn is_separator(&self) -> bool { matches!(self, PopupMenuItem::Separator) } + + fn has_left_icon(&self, check_side: Side) -> bool { + match self { + PopupMenuItem::Item { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::ElementItem { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::Submenu { icon, .. } => icon.is_some(), + _ => false, + } + } + + #[inline] + fn is_checked(&self) -> bool { + match self { + PopupMenuItem::Item { checked, .. } => *checked, + PopupMenuItem::ElementItem { checked, .. } => *checked, + _ => false, + } + } } pub struct PopupMenu { pub(crate) focus_handle: FocusHandle, pub(crate) menu_items: Vec, + /// The focus handle of Entity to handle actions. pub(crate) action_context: Option, - has_icon: bool, + + axis: Axis, selected_index: Option, min_width: Option, max_width: Option, max_height: Option, bounds: Bounds, size: Size, + check_side: Side, /// The parent menu of this menu, if this is a submenu parent_menu: Option>, scrollable: bool, external_link_icon: bool, scroll_handle: ScrollHandle, - scroll_state: ScrollbarState, - // This will update on render + + /// This will update on render submenu_anchor: (Corner, Pixels), _subscriptions: Vec, @@ -147,14 +311,14 @@ impl PopupMenu { parent_menu: None, menu_items: Vec::new(), selected_index: None, + axis: Axis::Vertical, min_width: None, max_width: None, max_height: None, - has_icon: false, + check_side: Side::Left, bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), - scroll_state: ScrollbarState::default(), external_link_icon: true, size: Size::default(), submenu_anchor: (Corner::TopLeft, Pixels::ZERO), @@ -198,11 +362,23 @@ impl PopupMenu { self } + /// Set the axis of children to horizontal. + pub fn horizontal(mut self) -> Self { + self.axis = Axis::Horizontal; + self + } + /// Set the menu to be scrollable to show vertical scrollbar. /// /// NOTE: If this is true, the sub-menus will cannot be support. - pub fn scrollable(mut self) -> Self { - self.scrollable = true; + pub fn scrollable(mut self, scrollable: bool) -> Self { + self.scrollable = scrollable; + self + } + + /// Set the side to show check icon, default is `Side::Left`. + pub fn check_side(mut self, side: Side) -> Self { + self.check_side = side; self } @@ -224,7 +400,7 @@ impl PopupMenu { action: Box, enable: bool, ) -> Self { - self.add_menu_item(label, None, action, !enable); + self.add_menu_item(label, None, action, !enable, false); self } @@ -235,13 +411,13 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, None, action, disabled); + self.add_menu_item(label, None, action, disabled, false); self } /// Add label pub fn label(mut self, label: impl Into) -> Self { - self.menu_items.push(PopupMenuItem::Label(label.into())); + self.menu_items.push(PopupMenuItem::label(label.into())); self } @@ -258,14 +434,8 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: None, - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items + .push(PopupMenuItem::link(label, href).disabled(disabled)); self } @@ -280,7 +450,7 @@ impl PopupMenu { } /// Add Menu to open link with icon and disabled state - pub fn link_with_icon_and_disabled( + fn link_with_icon_and_disabled( mut self, label: impl Into, icon: impl Into, @@ -288,14 +458,11 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: Some(icon.into()), - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items.push( + PopupMenuItem::link(label, href) + .icon(icon) + .disabled(disabled), + ); self } @@ -317,7 +484,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, Some(icon.into()), action, disabled); + self.add_menu_item(label, Some(icon.into()), action, disabled, false); self } @@ -339,12 +506,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - if checked { - self.add_menu_item(label, Some(IconName::Check.into()), action, disabled); - } else { - self.add_menu_item(label, None, action, disabled); - } - + self.add_menu_item(label, None, action, disabled, checked); self } @@ -385,29 +547,6 @@ impl PopupMenu { self.menu_element_with_icon_and_disabled(icon, action, false, builder) } - /// Add Menu Item with custom element render with icon and disabled state - pub fn menu_element_with_icon_and_disabled( - mut self, - icon: impl Into, - action: Box, - disabled: bool, - builder: F, - ) -> Self - where - F: Fn(&mut Window, &mut App) -> E + 'static, - E: IntoElement, - { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - icon: Some(icon.into()), - disabled, - handler: None, - }); - self.has_icon = true; - self - } - /// Add Menu Item with custom element render with check state pub fn menu_element_with_check( self, @@ -422,8 +561,29 @@ impl PopupMenu { self.menu_element_with_check_and_disabled(checked, action, false, builder) } + /// Add Menu Item with custom element render with icon and disabled state + fn menu_element_with_icon_and_disabled( + mut self, + icon: impl Into, + action: Box, + disabled: bool, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .icon(icon) + .disabled(disabled), + ); + self + } + /// Add Menu Item with custom element render with check state and disabled state - pub fn menu_element_with_check_and_disabled( + fn menu_element_with_check_and_disabled( mut self, checked: bool, action: Box, @@ -434,31 +594,12 @@ impl PopupMenu { F: Fn(&mut Window, &mut App) -> E + 'static, E: IntoElement, { - if checked { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: Some(IconName::Check.into()), - disabled, - }); - self.has_icon = true; - } else { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: None, - disabled, - }); - } - self - } - - /// Use small size, the menu item will have smaller height. - #[allow(dead_code)] - pub(crate) fn small(mut self) -> Self { - self.size = Size::Small; + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .checked(checked) + .disabled(disabled), + ); self } @@ -472,7 +613,7 @@ impl PopupMenu { return self; } - self.menu_items.push(PopupMenuItem::Separator); + self.menu_items.push(PopupMenuItem::separator()); self } @@ -487,36 +628,11 @@ impl PopupMenu { self.submenu_with_icon(None, label, window, cx, f) } - /// Add a Submenu item with disabled state - pub fn submenu_with_disabled( - self, - label: impl Into, - disabled: bool, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(None, label, disabled, window, cx, f) - } - /// Add a Submenu item with icon pub fn submenu_with_icon( - self, - icon: Option, - label: impl Into, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(icon, label, false, window, cx, f) - } - - /// Add a Submenu item with icon and disabled state - pub fn submenu_with_icon_with_disabled( mut self, icon: Option, label: impl Into, - disabled: bool, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, @@ -527,12 +643,23 @@ impl PopupMenu { view.parent_menu = Some(parent_menu); }); - self.menu_items.push(PopupMenuItem::Submenu { - icon, - label: label.into(), - menu: submenu, - disabled, - }); + self.menu_items.push( + PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)), + ); + self + } + + /// Add menu item. + pub fn item(mut self, item: impl Into) -> Self { + let item: PopupMenuItem = item.into(); + self.menu_items.push(item); + self + } + + /// Use small size, the menu item will have smaller height. + #[allow(dead_code)] + pub(crate) fn small(mut self) -> Self { + self.size = Size::Small; self } @@ -542,19 +669,15 @@ impl PopupMenu { icon: Option, action: Box, disabled: bool, + checked: bool, ) -> &mut Self { - if icon.is_some() { - self.has_icon = true; - } - - self.menu_items.push(PopupMenuItem::Item { - icon, - label: label.into(), - disabled, - action: Some(action.boxed_clone()), - is_link: false, - handler: None, - }); + self.menu_items.push( + PopupMenuItem::new(label) + .when_some(icon, |item, icon| item.icon(icon)) + .disabled(disabled) + .checked(checked) + .action(action), + ); self } @@ -569,9 +692,12 @@ impl PopupMenu { { for item in items { match item.into() { - OwnedMenuItem::Action { name, action, .. } => { - self = self.menu(name, action.boxed_clone()) - } + OwnedMenuItem::Action { + name, + action, + checked, + .. + } => self = self.menu_with_check(name, checked, action.boxed_clone()), OwnedMenuItem::Separator => { self = self.separator(); } @@ -625,13 +751,12 @@ impl PopupMenu { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(index) = self.selected_index { let item = self.menu_items.get(index); - match item { Some(PopupMenuItem::Item { handler, action, .. }) => { if let Some(handler) = handler { - handler(window, cx); + handler(&ClickEvent::default(), window, cx); } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action.as_ref(), window, cx); } @@ -642,8 +767,8 @@ impl PopupMenu { handler, action, .. }) => { if let Some(handler) = handler { - handler(window, cx); - } else { + handler(&ClickEvent::default(), window, cx); + } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action.as_ref(), window, cx); } self.dismiss(&Cancel, window, cx) @@ -765,7 +890,6 @@ impl PopupMenu { cx.notify(); return true; } - false } @@ -777,7 +901,6 @@ impl PopupMenu { }); return true; } - false } @@ -834,12 +957,39 @@ impl PopupMenu { }); } + fn handle_dismiss( + &mut self, + position: &Point, + window: &mut Window, + cx: &mut Context, + ) { + // Do not dismiss, if click inside the parent menu + if let Some(parent) = self.parent_menu.as_ref() { + if let Some(parent) = parent.upgrade() { + if parent.read(cx).bounds.contains(position) { + return; + } + } + } + + self.dismiss(&Cancel, window, cx); + } + + fn on_mouse_down_out( + &mut self, + e: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.handle_dismiss(&e.position, window, cx); + } + fn render_key_binding( &self, action: Option>, window: &mut Window, _: &mut Context, - ) -> Option { + ) -> Option { let action = action?; match self @@ -861,22 +1011,24 @@ impl PopupMenu { fn render_icon( has_icon: bool, + checked: bool, icon: Option, - _window: &mut Window, - _cx: &mut Context, + _: &mut Window, + _: &mut Context, ) -> Option { if !has_icon { return None; } - let icon = h_flex() - .w_3p5() - .h_3p5() - .justify_center() - .text_sm() - .when_some(icon, |this, icon| this.child(icon.clone().small())); + let icon = if let Some(icon) = icon { + icon.clone() + } else if checked { + Icon::new(IconName::Check) + } else { + Icon::empty() + }; - Some(icon) + Some(icon.xsmall()) } #[inline] @@ -906,22 +1058,28 @@ impl PopupMenu { &self, ix: usize, item: &PopupMenuItem, - state: ItemState, + options: RenderOptions, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { + ) -> MenuItemElement { + let has_left_icon = options.has_left_icon; + let is_left_check = options.check_side.is_left() && item.is_checked(); + let right_check_icon = if options.check_side.is_right() && item.is_checked() { + Some(Icon::new(IconName::Check).xsmall()) + } else { + None + }; + + let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(4.); const INNER_PADDING: Pixels = px(8.); - let has_icon = self.has_icon; - let selected = self.selected_index == Some(ix); - let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); - let group_name = format!("popup-menu-item-{ix}"); + let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); let (item_height, radius) = match self.size { - Size::Small => (px(20.), state.radius.half()), - _ => (px(26.), state.radius), + Size::Small => (px(20.), options.radius.half()), + _ => (px(26.), options.radius), }; let this = MenuItemElement::new(ix, &group_name) @@ -949,16 +1107,16 @@ impl PopupMenu { .p_0() .my_0p5() .mx_neg_1() - .h(px(1.)) - .bg(cx.theme().border) + .border_b(px(2.)) + .border_color(cx.theme().border) .disabled(true), PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( h_flex() .cursor_default() .items_center() - .font_semibold() - .text_xs() - .child(label.clone()), + .gap_x_1() + .children(Self::render_icon(has_left_icon, false, None, window, cx)) + .child(div().flex_1().child(label.clone())), ), PopupMenuItem::ElementItem { render, @@ -978,8 +1136,15 @@ impl PopupMenu { .min_h(item_height) .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) - .child((render)(window, cx)), + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) + .child((render)(window, cx)) + .children(right_check_icon.map(|icon| icon.ml_3())), ), PopupMenuItem::Item { icon, @@ -1000,14 +1165,22 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .gap_x_1() + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) .child( h_flex() .w_full() - .gap_2() + .gap_3() .items_center() .justify_between() .when(!show_link_icon, |this| this.child(label.clone())) + .children(right_check_icon) .when(show_link_icon, |this| { this.child( h_flex() @@ -1040,7 +1213,13 @@ impl PopupMenu { .size_full() .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .children(Self::render_icon( + has_left_icon, + false, + icon.clone(), + window, + cx, + )) .child( h_flex() .flex_1() @@ -1048,7 +1227,11 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) - .child(IconName::CaretRight), + .child( + Icon::new(IconName::CaretRight) + .xsmall() + .text_color(cx.theme().text_muted), + ), ), ) .when(selected, |this| { @@ -1085,7 +1268,9 @@ impl Focusable for PopupMenu { } #[derive(Clone, Copy)] -struct ItemState { +struct RenderOptions { + has_left_icon: bool, + check_side: Side, radius: Pixels, } @@ -1093,15 +1278,23 @@ impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.update_submenu_menu_anchor(window); - let max_width = self.max_width(); + let view = cx.entity().clone(); + let items_count = self.menu_items.len(); + let max_height = self.max_height.unwrap_or_else(|| { let window_half_height = window.window_bounds().get_bounds().size.height * 0.5; window_half_height.min(px(450.)) }); - let view = cx.entity().clone(); - let items_count = self.menu_items.len(); - let item_state = ItemState { + let has_left_icon = self + .menu_items + .iter() + .any(|item| item.has_left_icon(self.check_side)); + + let max_width = self.max_width(); + let options = RenderOptions { + has_left_icon, + check_side: self.check_side, radius: cx.theme().radius.min(px(8.)), }; @@ -1115,29 +1308,23 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::select_right)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) - .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| { - // Do not dismiss, if click inside the parent menu - if let Some(parent) = this.parent_menu.as_ref() { - if let Some(parent) = parent.upgrade() { - if parent.read(cx).bounds.contains(&ev.position) { - return; - } - } - } - - this.dismiss(&Cancel, window, cx); - })) + .on_mouse_down_out(cx.listener(Self::on_mouse_down_out)) .popover_style(cx) .text_color(cx.theme().text) .relative() + .occlude() .child( - v_flex() + div() .id("items") .p_1() .gap_y_0p5() .min_w(rems(8.)) .when_some(self.min_width, |this, min_width| this.min_w(min_width)) .max_w(max_width) + .map(|this| match self.axis { + Axis::Horizontal => this.flex().flex_row().items_center(), + Axis::Vertical => this.flex().flex_col(), + }) .when(self.scrollable, |this| { this.max_h(max_height) .overflow_y_scroll() @@ -1149,28 +1336,13 @@ impl Render for PopupMenu { .enumerate() // Ignore last separator .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator())) - .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)), + .map(|(ix, item)| self.render_item(ix, item, options, window, cx)), ) - .child({ - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full() - }), + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)), ) .when(self.scrollable, |this| { // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed. - this.child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), - ) + this.vertical_scrollbar(&self.scroll_handle) }) } } diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 226dcee..1e445c4 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -3,7 +3,7 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, + anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, @@ -13,6 +13,7 @@ use theme::ActiveTheme; use crate::actions::{Cancel, Confirm}; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; +use crate::scroll::ScrollableElement; use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension}; const CONTEXT: &str = "Modal"; @@ -489,13 +490,13 @@ impl RenderOnce for Modal { .w_full() .h_auto() .flex_1() - .relative() .overflow_hidden() .child( v_flex() .pr(padding_right) .pl(padding_left) - .scrollable(Axis::Vertical) + .size_full() + .overflow_y_scrollbar() .child(self.content), ), ) diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index df42c93..9f5d826 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,129 +1,78 @@ -use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent, - DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, - LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, - ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window, + deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, + EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, + Styled, Subscription, Window, }; -use crate::{Selectable, StyledExt as _}; +use crate::actions::Cancel; +use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _}; const CONTEXT: &str = "Popover"; -actions!(popover, [Escape]); - -pub fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) } -type PopoverChild = Rc) -> AnyElement>; - -pub struct PopoverContent { - focus_handle: FocusHandle, - scroll_handle: ScrollHandle, - max_width: Option, - max_height: Option, - scrollable: bool, - child: PopoverChild, -} - -impl PopoverContent { - pub fn new(_window: &mut Window, cx: &mut App, content: B) -> Self - where - B: Fn(&mut Window, &mut Context) -> AnyElement + 'static, - { - let focus_handle = cx.focus_handle(); - let scroll_handle = ScrollHandle::default(); - - Self { - focus_handle, - scroll_handle, - child: Rc::new(content), - max_width: None, - max_height: None, - scrollable: false, - } - } - - pub fn max_w(mut self, max_width: Pixels) -> Self { - self.max_width = Some(max_width); - self - } - - pub fn max_h(mut self, max_height: Pixels) -> Self { - self.max_height = Some(max_height); - self - } - - pub fn scrollable(mut self) -> Self { - self.scrollable = true; - self - } -} - -impl EventEmitter for PopoverContent {} - -impl Focusable for PopoverContent { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PopoverContent { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("popup-content") - .track_focus(&self.focus_handle) - .key_context(CONTEXT) - .on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent))) - .p_2() - .when(self.scrollable, |this| { - this.overflow_y_scroll().track_scroll(&self.scroll_handle) - }) - .when_some(self.max_width, |this, v| this.max_w(v)) - .when_some(self.max_height, |this, v| this.max_h(v)) - .child(self.child.clone()(window, cx)) - } -} - -type Trigger = Option AnyElement + 'static>>; -type Content = Option Entity + 'static>>; - -pub struct Popover { +/// A popover element that can be triggered by a button or any other element. +#[derive(IntoElement)] +pub struct Popover { id: ElementId, - anchor: Corner, - trigger: Trigger, - content: Content, + style: StyleRefinement, + anchor: Anchor, + default_open: bool, + open: Option, + tracked_focus_handle: Option, + #[allow(clippy::type_complexity)] + trigger: Option AnyElement + 'static>>, + #[allow(clippy::type_complexity)] + content: Option< + Rc< + dyn Fn(&mut PopoverState, &mut Window, &mut Context) -> AnyElement + + 'static, + >, + >, + children: Vec, /// Style for trigger element. /// This is used for hotfix the trigger element style to support w_full. trigger_style: Option, mouse_button: MouseButton, - no_style: bool, + appearance: bool, + overlay_closable: bool, + #[allow(clippy::type_complexity)] + on_open_change: Option>, } -impl Popover -where - M: ManagedView, -{ +impl Popover { /// Create a new Popover with `view` mode. pub fn new(id: impl Into) -> Self { Self { id: id.into(), - anchor: Corner::TopLeft, + style: StyleRefinement::default(), + anchor: Anchor::TopLeft, trigger: None, trigger_style: None, content: None, + tracked_focus_handle: None, + children: vec![], mouse_button: MouseButton::Left, - no_style: false, + appearance: true, + overlay_closable: true, + default_open: false, + open: None, + on_open_change: None, } } - pub fn anchor(mut self, anchor: Corner) -> Self { - self.anchor = anchor; + /// Set the anchor corner of the popover, default is `Corner::TopLeft`. + /// + /// This method is kept for backward compatibility with `Corner` type. + /// Internally, it converts `Corner` to `Anchor`. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); self } @@ -133,29 +82,75 @@ where self } + /// Set the trigger element of the popover. pub fn trigger(mut self, trigger: T) -> Self where T: Selectable + IntoElement + 'static, { self.trigger = Some(Box::new(|is_open, _, _| { - trigger.selected(is_open).into_any_element() + let selected = trigger.is_selected(); + trigger.selected(selected || is_open).into_any_element() })); self } + /// Set the default open state of the popover, default is `false`. + /// + /// This is only used to initialize the open state of the popover. + /// + /// And please note that if you use the `open` method, this value will be ignored. + pub fn default_open(mut self, open: bool) -> Self { + self.default_open = open; + self + } + + /// Force set the open state of the popover. + /// + /// If this is set, the popover will be controlled by this value. + /// + /// NOTE: You must be used in conjunction with `on_open_change` to handle state changes. + pub fn open(mut self, open: bool) -> Self { + self.open = Some(open); + self + } + + /// Add a callback to be called when the open state changes. + /// + /// The first `&bool` parameter is the **new open state**. + /// + /// This is useful when using the `open` method to control the popover state. + pub fn on_open_change(mut self, callback: F) -> Self + where + F: Fn(&bool, &mut Window, &mut App) + 'static, + { + self.on_open_change = Some(Rc::new(callback)); + self + } + + /// Set the style for the trigger element. pub fn trigger_style(mut self, style: StyleRefinement) -> Self { self.trigger_style = Some(style); self } - /// Set the content of the popover. + /// Set whether clicking outside the popover will dismiss it, default is `true`. + pub fn overlay_closable(mut self, closable: bool) -> Self { + self.overlay_closable = closable; + self + } + + /// Set the content builder for content of the Popover. /// - /// The `content` is a closure that returns an `AnyElement`. - pub fn content(mut self, content: C) -> Self + /// This callback will called every time on render the popover. + /// So, you should avoid creating new elements or entities in the content closure. + pub fn content(mut self, content: F) -> Self where - C: Fn(&mut Window, &mut App) -> Entity + 'static, + E: IntoElement, + F: Fn(&mut PopoverState, &mut Window, &mut Context) -> E + 'static, { - self.content = Some(Rc::new(content)); + self.content = Some(Rc::new(move |state, window, cx| { + content(state, window, cx).into_any_element() + })); self } @@ -165,302 +160,265 @@ where /// /// - The popover will not have a bg, border, shadow, or padding. /// - The click out of the popover will not dismiss it. - pub fn no_style(mut self) -> Self { - self.no_style = true; + pub fn appearance(mut self, appearance: bool) -> Self { + self.appearance = appearance; self } - fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement { - let Some(trigger) = self.trigger.take() else { - return div().into_any_element(); + /// Bind the focus handle to receive focus when the popover is opened. + /// If you not set this, a new focus handle will be created for the popover to + /// + /// If popover is opened, the focus will be moved to the focus handle. + pub fn track_focus(mut self, handle: &FocusHandle) -> Self { + self.tracked_focus_handle = Some(handle.clone()); + self + } + + fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds) -> Point { + let offset = if anchor.is_center() { + gpui::point(trigger_bounds.size.width.half(), px(0.)) + } else { + Point::default() }; - (trigger)(is_open, window, cx) - } - - fn resolved_corner(&self, bounds: Bounds) -> Point { - bounds.corner(match self.anchor { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, - }) - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut PopoverElementState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::, _>( - Some(id), - |element_state, window| { - let mut element_state = element_state.unwrap().unwrap_or_default(); - let result = f(self, &mut element_state, window, cx); - (result, Some(element_state)) - }, - ) + trigger_bounds.corner(anchor.swap_vertical().into()) + + offset + + Point { + x: px(0.), + y: -trigger_bounds.size.height, + } } } -impl IntoElement for Popover -where - M: ManagedView, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self +impl ParentElement for Popover { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); } } -pub struct PopoverElementState { - trigger_layout_id: Option, - popover_layout_id: Option, - popover_element: Option, - trigger_element: Option, - content_view: Rc>>>, - /// Trigger bounds for positioning the popover. - trigger_bounds: Option>, +impl Styled for Popover { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } } -impl Default for PopoverElementState { - fn default() -> Self { +pub struct PopoverState { + focus_handle: FocusHandle, + pub(crate) tracked_focus_handle: Option, + trigger_bounds: Bounds, + open: bool, + #[allow(clippy::type_complexity)] + on_open_change: Option>, + + _dismiss_subscription: Option, +} + +impl PopoverState { + pub fn new(default_open: bool, cx: &mut App) -> Self { Self { - trigger_layout_id: None, - popover_layout_id: None, - popover_element: None, - trigger_element: None, - content_view: Rc::new(RefCell::new(None)), - trigger_bounds: None, - } - } -} - -pub struct PrepaintState { - hitbox: Hitbox, - /// Trigger bounds for limit a rect to handle mouse click. - trigger_bounds: Option>, -} - -impl Element for Popover { - type PrepaintState = PrepaintState; - type RequestLayoutState = PopoverElementState; - - 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<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - - // FIXME: Remove this and find a better way to handle this. - // Apply trigger style, for support w_full for trigger. - // - // If remove this, the trigger will not support w_full. - if let Some(trigger_style) = self.trigger_style.clone() { - if let Some(width) = trigger_style.size.width { - style.size.width = width; - } - if let Some(display) = trigger_style.display { - style.display = display; - } - } - - self.with_element_state( - id.unwrap(), - window, - cx, - |view, element_state, window, cx| { - let mut popover_layout_id = None; - let mut popover_element = None; - let mut is_open = false; - - if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() { - is_open = true; - - let mut anchored = anchored() - .snap_to_window_with_margin(px(8.)) - .anchor(view.anchor); - if let Some(trigger_bounds) = element_state.trigger_bounds { - anchored = anchored.position(view.resolved_corner(trigger_bounds)); - } - - let mut element = { - let content_view_mut = element_state.content_view.clone(); - let anchor = view.anchor; - let no_style = view.no_style; - deferred( - anchored.child( - div() - .size_full() - .occlude() - .when(!no_style, |this| this.popover_style(cx)) - .map(|this| match anchor { - Corner::TopLeft | Corner::TopRight => this.top_1p5(), - Corner::BottomLeft | Corner::BottomRight => { - this.bottom_1p5() - } - }) - .child(content_view.clone()) - .when(!no_style, |this| { - this.on_mouse_down_out(move |_, window, _| { - // Update the element_state.content_view to `None`, - // so that the `paint`` method will not paint it. - *content_view_mut.borrow_mut() = None; - window.refresh(); - }) - }), - ), - ) - .with_priority(1) - .into_any() - }; - - popover_layout_id = Some(element.request_layout(window, cx)); - popover_element = Some(element); - } - - let mut trigger_element = view.render_trigger(is_open, window, cx); - let trigger_layout_id = trigger_element.request_layout(window, cx); - - let layout_id = window.request_layout( - style, - Some(trigger_layout_id).into_iter().chain(popover_layout_id), - cx, - ); - - ( - layout_id, - PopoverElementState { - trigger_layout_id: Some(trigger_layout_id), - popover_layout_id, - popover_element, - trigger_element: Some(trigger_element), - ..Default::default() - }, - ) - }, - ) - } - - fn prepaint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: gpui::Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - if let Some(element) = &mut request_layout.trigger_element { - element.prepaint(window, cx); - } - if let Some(element) = &mut request_layout.popover_element { - element.prepaint(window, cx); - } - - let trigger_bounds = request_layout - .trigger_layout_id - .map(|id| window.layout_bounds(id)); - - // Prepare the popover, for get the bounds of it for open window size. - let _ = request_layout - .popover_layout_id - .map(|id| window.layout_bounds(id)); - - let hitbox = - window.insert_hitbox(trigger_bounds.unwrap_or_default(), HitboxBehavior::Normal); - - PrepaintState { - trigger_bounds, - hitbox, + focus_handle: cx.focus_handle(), + tracked_focus_handle: None, + trigger_bounds: Bounds::default(), + open: default_open, + on_open_change: None, + _dismiss_subscription: None, } } - fn paint( - &mut self, - id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.with_element_state( - id.unwrap(), - window, - cx, - |this, element_state, window, cx| { - element_state.trigger_bounds = prepaint.trigger_bounds; + /// Check if the popover is open. + pub fn is_open(&self) -> bool { + self.open + } - if let Some(mut element) = request_layout.trigger_element.take() { - element.paint(window, cx); - } + /// Dismiss the popover if it is open. + pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + self.toggle_open(window, cx); + } + } - if let Some(mut element) = request_layout.popover_element.take() { - element.paint(window, cx); - return; - } + /// Open the popover if it is closed. + pub fn show(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + self.toggle_open(window, cx); + } + } - // When mouse click down in the trigger bounds, open the popover. - let Some(content_build) = this.content.take() else { - return; - }; - let old_content_view = element_state.content_view.clone(); - let hitbox_id = prepaint.hitbox.id; - let mouse_button = this.mouse_button; - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble - && event.button == mouse_button - && hitbox_id.is_hovered(window) - { - cx.stop_propagation(); - window.prevent_default(); + fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + self.open = !self.open; + if self.open { + let state = cx.entity(); + let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() + { + tracked_focus_handle + } else { + self.focus_handle.clone() + }; + focus_handle.focus(window, cx); - let new_content_view = (content_build)(window, cx); - let old_content_view1 = old_content_view.clone(); - - let previous_focus_handle = window.focused(cx); - - window - .subscribe( - &new_content_view, - cx, - move |modal, _: &DismissEvent, window, cx| { - if modal.focus_handle(cx).contains_focused(window, cx) { - if let Some(previous_focus_handle) = - previous_focus_handle.as_ref() - { - window.focus(previous_focus_handle, cx); - } - } - *old_content_view1.borrow_mut() = None; - - window.refresh(); - }, - ) - .detach(); - - window.focus(&new_content_view.focus_handle(cx), cx); - *old_content_view.borrow_mut() = Some(new_content_view); + self._dismiss_subscription = + Some( + window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); window.refresh(); - } - }); - }, - ); + }), + ); + } else { + self._dismiss_subscription = None; + } + + if let Some(callback) = self.on_open_change.as_ref() { + callback(&self.open, window, cx); + } + cx.notify(); + } + + fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.dismiss(window, cx); + } +} + +impl Focusable for PopoverState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PopoverState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +impl EventEmitter for PopoverState {} + +impl Popover { + pub(crate) fn render_popover( + anchor: Anchor, + trigger_bounds: Bounds, + content: E, + _: &mut Window, + _: &mut App, + ) -> Deferred + where + E: IntoElement + 'static, + { + deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .position(Self::resolved_corner(anchor, trigger_bounds)) + .child(div().relative().child(content)), + ) + .with_priority(1) + } + + pub(crate) fn render_popover_content( + anchor: Anchor, + appearance: bool, + _: &mut Window, + cx: &mut App, + ) -> Stateful
{ + v_flex() + .id("content") + .occlude() + .tab_group() + .when(appearance, |this| this.popover_style(cx).p_3()) + .map(|this| match anchor { + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), + Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), + }) + } +} + +impl RenderOnce for Popover { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let force_open = self.open; + let default_open = self.default_open; + let tracked_focus_handle = self.tracked_focus_handle.clone(); + let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { + PopoverState::new(default_open, cx) + }); + + state.update(cx, |state, _| { + if let Some(tracked_focus_handle) = tracked_focus_handle { + state.tracked_focus_handle = Some(tracked_focus_handle); + } + state.on_open_change = self.on_open_change.clone(); + if let Some(force_open) = force_open { + state.open = force_open; + } + }); + + let open = state.read(cx).open; + let focus_handle = state.read(cx).focus_handle.clone(); + let trigger_bounds = state.read(cx).trigger_bounds; + + let Some(trigger) = self.trigger else { + return div().id("empty"); + }; + + let parent_view_id = window.current_view(); + + let el = div() + .id(self.id) + .child((trigger)(open, window, cx)) + .on_mouse_down(self.mouse_button, { + let state = state.clone(); + move |_, window, cx| { + cx.stop_propagation(); + state.update(cx, |state, cx| { + // We force set open to false to toggle it correctly. + // Because if the mouse down out will toggle open first. + state.open = open; + state.toggle_open(window, cx); + }); + cx.notify(parent_view_id); + } + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, _| { + state.trigger_bounds = bounds; + }) + } + }); + + if !open { + return el; + } + + let popover_content = + Self::render_popover_content(self.anchor, self.appearance, window, cx) + .track_focus(&focus_handle) + .key_context(CONTEXT) + .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) + .when_some(self.content, |this, content| { + this.child(state.update(cx, |state, cx| (content)(state, window, cx))) + }) + .children(self.children) + .when(self.overlay_closable, |this| { + this.on_mouse_down_out({ + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + cx.notify(parent_view_id); + } + }) + }) + .refine_style(&self.style); + + el.child(Self::render_popover( + self.anchor, + trigger_bounds, + popover_content, + window, + cx, + )) } } diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index 0738775..821694e 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -1,232 +1,209 @@ +use std::panic::Location; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId, - InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, - Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement, - Style, StyleRefinement, Styled, Window, + div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, }; -use super::{Scrollbar, ScrollbarAxis, ScrollbarState}; +use super::{Scrollbar, ScrollbarAxis}; +use crate::scroll::ScrollbarHandle; +use crate::StyledExt; -/// A scroll view is a container that allows the user to scroll through a large amount of content. -pub struct Scrollable { +/// A trait for elements that can be made scrollable with scrollbars. +pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element { + /// Adds a scrollbar to the element. + #[track_caller] + fn scrollbar( + self, + scroll_handle: &H, + axis: impl Into, + ) -> Self { + self.child(ScrollbarLayer { + id: "scrollbar_layer".into(), + axis: axis.into(), + scroll_handle: Rc::new(scroll_handle.clone()), + }) + } + + /// Adds a vertical scrollbar to the element. + #[track_caller] + fn vertical_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Vertical) + } + /// Adds a horizontal scrollbar to the element. + #[track_caller] + fn horizontal_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars. + #[track_caller] + fn overflow_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Both) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar. + #[track_caller] + fn overflow_x_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar. + #[track_caller] + fn overflow_y_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Vertical) + } +} + +/// A scrollable element wrapper that adds scrollbars to an interactive element. +#[derive(IntoElement)] +pub struct Scrollable { id: ElementId, - element: Option, + element: E, axis: ScrollbarAxis, - /// This is a fake element to handle Styled, InteractiveElement, not used. - _element: Stateful
, } impl Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element, { - pub(crate) fn new(axis: impl Into, element: E) -> Self { - let id = ElementId::Name(SharedString::from( - format!("scrollable-{:?}", element.id(),), - )); - + #[track_caller] + fn new(element: E, axis: impl Into) -> Self { + let caller = Location::caller(); Self { - element: Some(element), - _element: div().id("fake"), - id, + id: ElementId::CodeLocation(*caller), + element, axis: axis.into(), } } - - /// Set only a vertical scrollbar. - pub fn vertical(mut self) -> Self { - self.set_axis(ScrollbarAxis::Vertical); - self - } - - /// Set only a horizontal scrollbar. - /// In current implementation, this is not supported yet. - pub fn horizontal(mut self) -> Self { - self.set_axis(ScrollbarAxis::Horizontal); - self - } - - /// Set the axis of the scroll view. - pub fn set_axis(&mut self, axis: impl Into) { - self.axis = axis.into(); - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::( - Some(id), - |element_state, window| { - let mut element_state = element_state.unwrap().unwrap_or_default(); - let result = f(self, &mut element_state, window, cx); - (result, Some(element_state)) - }, - ) - } -} - -pub struct ScrollViewState { - state: ScrollbarState, - handle: ScrollHandle, -} - -impl Default for ScrollViewState { - fn default() -> Self { - Self { - handle: ScrollHandle::new(), - state: ScrollbarState::default(), - } - } -} - -impl ParentElement for Scrollable -where - E: Element + ParentElement, -{ - fn extend(&mut self, elements: impl IntoIterator) { - if let Some(element) = &mut self.element { - element.extend(elements); - } - } } impl Styled for Scrollable where - E: Element + Styled, + E: InteractiveElement + Styled + ParentElement + Element, { fn style(&mut self) -> &mut StyleRefinement { - if let Some(element) = &mut self.element { - element.style() - } else { - self._element.style() - } + self.element.style() } } -impl InteractiveElement for Scrollable +impl ParentElement for Scrollable where - E: Element + InteractiveElement, + E: InteractiveElement + Styled + ParentElement + Element, { - fn interactivity(&mut self) -> &mut Interactivity { - if let Some(element) = &mut self.element { - element.interactivity() - } else { - self._element.interactivity() - } - } -} -impl StatefulInteractiveElement for Scrollable where E: Element + StatefulInteractiveElement {} - -impl IntoElement for Scrollable -where - E: Element, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self + fn extend(&mut self, elements: impl IntoIterator) { + self.element.extend(elements) } } -impl Element for Scrollable +impl InteractiveElement for Scrollable
{ + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl InteractiveElement for Scrollable> { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl RenderOnce for Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element + 'static, { - type PrepaintState = ScrollViewState; - type RequestLayoutState = AnyElement; + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let scroll_handle = window + .use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default()) + .read(cx) + .clone(); - 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<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Relative, - flex_grow: 1.0, - flex_shrink: 1.0, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, + // Inherit the size from the element style. + let style = StyleRefinement { + size: self.element.style().size.clone(), ..Default::default() }; - let axis = self.axis; - let scroll_id = self.id.clone(); - let content = self.element.take().map(|c| c.into_any_element()); - - self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| { - let mut element = div() - .relative() - .size_full() - .overflow_hidden() - .child( - div() - .id(scroll_id) - .track_scroll(&element_state.handle) - .overflow_scroll() - .relative() - .size_full() - .child(div().children(content)), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child( - Scrollbar::both(&element_state.state, &element_state.handle).axis(axis), - ), - ) - .into_any_element(); - - let element_id = element.request_layout(window, cx); - let layout_id = window.request_layout(style, vec![element_id], cx); - - (layout_id, element) - }) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - element.prepaint(window, cx); - // do nothing - ScrollViewState::default() - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - element.paint(window, cx) + div() + .id(self.id) + .size_full() + .refine_style(&style) + .relative() + .child( + div() + .id("scroll-area") + .flex() + .size_full() + .track_scroll(&scroll_handle) + .map(|this| match self.axis { + ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(), + ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(), + ScrollbarAxis::Both => this.overflow_scroll(), + }) + .child( + self.element + // Refine element size to `flex_1`. + .size_auto() + .flex_1(), + ), + ) + .child(render_scrollbar( + "scrollbar", + &scroll_handle, + self.axis, + window, + cx, + )) } } + +impl ScrollableElement for Div {} +impl ScrollableElement for Stateful +where + E: ParentElement + Styled + Element, + Self: InteractiveElement, +{ +} + +#[derive(IntoElement)] +struct ScrollbarLayer { + id: ElementId, + axis: ScrollbarAxis, + scroll_handle: Rc, +} + +impl RenderOnce for ScrollbarLayer +where + H: ScrollbarHandle + Clone + 'static, +{ + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx) + } +} + +#[inline] +#[track_caller] +fn render_scrollbar( + id: impl Into, + scroll_handle: &H, + axis: ScrollbarAxis, + window: &mut Window, + cx: &mut App, +) -> Div { + // Do not render scrollbar when inspector is picking elements, + // to allow us to pick the background elements. + let is_inspector_picking = window.is_inspector_picking(cx); + if is_inspector_picking { + return div(); + } + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::new(scroll_handle).id(id).axis(axis)) +} diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 394dff7..e78a3b7 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -1,43 +1,50 @@ use std::cell::Cell; use std::ops::Deref; +use std::panic::Location; use std::rc::Rc; use std::time::{Duration, Instant}; use gpui::{ fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, - CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, - IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window, + CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, + UniformListScrollHandle, Window, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, ScrollbarMode}; use crate::AxisExt; -const WIDTH: Pixels = px(2. * 2. + 8.); +/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) +const WIDTH: Pixels = px(1. * 2. + 8.); const MIN_THUMB_SIZE: f32 = 48.; const THUMB_WIDTH: Pixels = px(6.); const THUMB_RADIUS: Pixels = px(6. / 2.); -const THUMB_INSET: Pixels = px(2.); +const THUMB_INSET: Pixels = px(1.); const THUMB_ACTIVE_WIDTH: Pixels = px(8.); const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); -const THUMB_ACTIVE_INSET: Pixels = px(2.); +const THUMB_ACTIVE_INSET: Pixels = px(1.); const FADE_OUT_DURATION: f32 = 3.0; const FADE_OUT_DELAY: f32 = 2.0; -pub trait ScrollHandleOffsetable { +/// A trait for scroll handles that can get and set offset. +pub trait ScrollbarHandle: 'static { + /// Get the current offset of the scroll handle. fn offset(&self) -> Point; + /// Set the offset of the scroll handle. fn set_offset(&self, offset: Point); - fn is_uniform_list(&self) -> bool { - false - } /// The full size of the content, including padding. fn content_size(&self) -> Size; + /// Called when start dragging the scrollbar thumb. + fn start_drag(&self) {} + /// Called when end dragging the scrollbar thumb. + fn end_drag(&self) {} } -impl ScrollHandleOffsetable for ScrollHandle { +impl ScrollbarHandle for ScrollHandle { fn offset(&self) -> Point { self.offset() } @@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle { } } -impl ScrollHandleOffsetable for UniformListScrollHandle { +impl ScrollbarHandle for UniformListScrollHandle { fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } @@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle { self.0.borrow_mut().base_handle.set_offset(offset) } - fn is_uniform_list(&self) -> bool { - true - } - fn content_size(&self) -> Size { let base_handle = &self.0.borrow().base_handle; base_handle.max_offset() + base_handle.bounds().size } } -#[derive(Debug, Clone)] -pub struct ScrollbarState(Rc>); +impl ScrollbarHandle for ListState { + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + fn set_offset(&self, offset: Point) { + self.set_offset_from_scrollbar(offset); + } + + fn content_size(&self) -> Size { + self.viewport_bounds().size + self.max_offset_for_scrollbar() + } + + fn start_drag(&self) { + self.scrollbar_drag_started(); + } + + fn end_drag(&self) { + self.scrollbar_drag_ended(); + } +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +struct ScrollbarState(Rc>); + +#[doc(hidden)] #[derive(Debug, Clone, Copy)] -pub struct ScrollbarStateInner { +struct ScrollbarStateInner { hovered_axis: Option, hovered_on_thumb: Option, dragged_axis: Option, @@ -83,6 +110,7 @@ pub struct ScrollbarStateInner { last_scroll_time: Option, // Last update offset last_update: Instant, + idle_timer_scheduled: bool, } impl Default for ScrollbarState { @@ -95,6 +123,7 @@ impl Default for ScrollbarState { last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, last_update: Instant::now(), + idle_timer_scheduled: false, }))) } } @@ -167,6 +196,12 @@ impl ScrollbarStateInner { state } + fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { + let mut state = *self; + state.idle_timer_scheduled = scheduled; + state + } + fn is_scrollbar_visible(&self) -> bool { // On drag if self.dragged_axis.is_some() { @@ -182,10 +217,14 @@ impl ScrollbarStateInner { } } +/// Scrollbar axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrollbarAxis { + /// Vertical scrollbar. Vertical, + /// Horizontal scrollbar. Horizontal, + /// Show both vertical and horizontal scrollbars. Both, } @@ -200,25 +239,30 @@ impl From for ScrollbarAxis { impl ScrollbarAxis { /// Return true if the scrollbar axis is vertical. + #[inline] pub fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } /// Return true if the scrollbar axis is horizontal. + #[inline] pub fn is_horizontal(&self) -> bool { matches!(self, Self::Horizontal) } /// Return true if the scrollbar axis is both vertical and horizontal. + #[inline] pub fn is_both(&self) -> bool { matches!(self, Self::Both) } + /// Return true if the scrollbar has vertical axis. #[inline] pub fn has_vertical(&self) -> bool { matches!(self, Self::Vertical | Self::Both) } + /// Return true if the scrollbar has horizontal axis. #[inline] pub fn has_horizontal(&self) -> bool { matches!(self, Self::Horizontal | Self::Both) @@ -238,9 +282,10 @@ impl ScrollbarAxis { /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { + pub(crate) id: ElementId, axis: ScrollbarAxis, - scroll_handle: Rc>, - state: ScrollbarState, + scrollbar_mode: Option, + scroll_handle: Rc, scroll_size: Option>, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// @@ -250,50 +295,46 @@ pub struct Scrollbar { } impl Scrollbar { - fn new( - axis: impl Into, - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { + /// Create a new scrollbar. + /// + /// This will have both vertical and horizontal scrollbars. + #[track_caller] + pub fn new(scroll_handle: &H) -> Self { + let caller = Location::caller(); Self { - state: state.clone(), - axis: axis.into(), - scroll_handle: Rc::new(Box::new(scroll_handle.clone())), + id: ElementId::CodeLocation(*caller), + axis: ScrollbarAxis::Both, + scrollbar_mode: None, + scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, scroll_size: None, } } - /// Create with vertical and horizontal scrollbar. - pub fn both( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Both, state, scroll_handle) - } - /// Create with horizontal scrollbar. - pub fn horizontal( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Horizontal, state, scroll_handle) + #[track_caller] + pub fn horizontal(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) } /// Create with vertical scrollbar. - pub fn vertical( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + #[track_caller] + pub fn vertical(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) } - /// Create vertical scrollbar for uniform list. - pub fn uniform_scroll( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + /// Set a specific element id, default is the [`Location::caller`]. + /// + /// NOTE: In most cases, you don't need to set a specific id for scrollbar. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`. + pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self { + self.scrollbar_mode = Some(mode); + self } /// Set a special scroll size of the content area, default is None. @@ -315,11 +356,18 @@ impl Scrollbar { /// If you have very high CPU usage, consider reducing this value to improve performance. /// /// Available values: 30..120 - pub fn max_fps(mut self, max_fps: usize) -> Self { + #[allow(dead_code)] + pub(crate) fn max_fps(mut self, max_fps: usize) -> Self { self.max_fps = max_fps.clamp(30, 120); self } + // Get the width of the scrollbar. + #[allow(dead_code)] + pub(crate) const fn width() -> Pixels { + WIDTH + } + fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, @@ -353,11 +401,28 @@ impl Scrollbar { ) } - fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() { - (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS) - } else { - (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS) + fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), + }; + + ( + cx.theme().scrollbar_thumb_background, + cx.theme().scrollbar_track_background, + gpui::transparent_black(), + width, + inset, + radius, + ) + } + + fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), }; ( @@ -379,11 +444,14 @@ impl IntoElement for Scrollbar { } } +#[doc(hidden)] pub struct PrepaintState { hitbox: Hitbox, + scrollbar_state: ScrollbarState, states: Vec, } +#[doc(hidden)] pub struct AxisPrepaintState { axis: Axis, bar_hitbox: Hitbox, @@ -406,7 +474,7 @@ impl Element for Scrollbar { type RequestLayoutState = (); fn id(&self) -> Option { - None + Some(self.id.clone()) } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { @@ -420,11 +488,11 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let style = gpui::Style { + let style = Style { position: Position::Absolute, flex_grow: 1.0, flex_shrink: 1.0, - size: gpui::Size { + size: Size { width: relative(1.).into(), height: relative(1.).into(), }, @@ -447,6 +515,11 @@ impl Element for Scrollbar { window.insert_hitbox(bounds, HitboxBehavior::Normal) }); + let state = window + .use_state(cx, |_, _| ScrollbarState::default()) + .read(cx) + .clone(); + let mut states = vec![]; let mut has_both = self.axis.is_both(); let scroll_size = self @@ -470,9 +543,8 @@ impl Element for Scrollbar { }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. - let margin_end = if has_both && !is_vertical { - THUMB_ACTIVE_WIDTH + WIDTH } else { px(0.) }; @@ -512,11 +584,12 @@ impl Element for Scrollbar { }, }; - let state = self.state.clone(); - let is_always_to_show = cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let is_always_to_show = scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); + let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset(); let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = if state.get().dragged_axis == Some(axis) { @@ -527,38 +600,47 @@ impl Element for Scrollbar { } else { Self::style_for_hovered_bar(cx) } + } else if is_offset_changed { + self.style_for_normal(cx) } else if is_always_to_show { - #[allow(clippy::if_same_then_else)] if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) } } else { - let mut idle_state = Self::style_for_idle(cx); + let mut idle_state = self.style_for_idle(cx); // Delay 2s to fade out the scrollbar thumb (in 1s) if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - if elapsed < FADE_OUT_DURATION { - if is_hovered_on_bar { - state.set(state.get().with_last_scroll_time(Some(Instant::now()))); - idle_state = if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - }; + if is_hovered_on_bar { + state.set(state.get().with_last_scroll_time(Some(Instant::now()))); + idle_state = if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) } else { - if elapsed < FADE_OUT_DELAY { - idle_state.0 = cx.theme().scrollbar_thumb_background; - } else { - // opacity = 1 - (x - 2)^10 - let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); - idle_state.0 = - cx.theme().scrollbar_thumb_background.opacity(opacity); - }; + Self::style_for_hovered_bar(cx) + }; + } else if elapsed < FADE_OUT_DELAY { + idle_state.0 = cx.theme().scrollbar_thumb_background; - window.request_animation_frame(); + if !state.get().idle_timer_scheduled { + let state = state.clone(); + state.set(state.get().with_idle_timer_scheduled(true)); + let current_view = window.current_view(); + let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(next_delay).await; + state.set(state.get().with_idle_timer_scheduled(false)); + cx.update(|_, cx| cx.notify(current_view)).ok(); + }) + .detach(); } + } else if elapsed < FADE_OUT_DURATION { + let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); + idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity); + + window.request_animation_frame(); } } @@ -617,7 +699,11 @@ impl Element for Scrollbar { }) } - PrepaintState { hitbox, states } + PrepaintState { + hitbox, + states, + scrollbar_state: state, + } } fn paint( @@ -630,19 +716,21 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) { + let scrollbar_state = &prepaint.scrollbar_state; + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let view_id = window.current_view(); let hitbox_bounds = prepaint.hitbox.bounds; - let is_visible = - self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); // Update last_scroll_time when offset is changed. - if self.scroll_handle.offset() != self.state.get().last_scroll_offset { - self.state.set( - self.state + if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset { + scrollbar_state.set( + scrollbar_state .get() .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), ); + cx.notify(view_id); } window.with_content_mask( @@ -652,7 +740,10 @@ impl Element for Scrollbar { |window| { for state in prepaint.states.iter() { let axis = state.axis; - let radius = state.radius; + let mut radius = state.radius; + if cx.theme().radius.is_zero() { + radius = px(0.); + } let bounds = state.bounds; let thumb_bounds = state.thumb_bounds; let scroll_area_size = state.scroll_size; @@ -686,7 +777,7 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, _, cx| { @@ -707,7 +798,7 @@ impl Element for Scrollbar { if is_hover_to_show || is_visible { window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, _, cx| { @@ -718,6 +809,7 @@ impl Element for Scrollbar { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; + scroll_handle.start_drag(); state.set(state.get().with_drag_pos(axis, pos)); cx.notify(view_id); @@ -755,7 +847,7 @@ impl Element for Scrollbar { window.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); - let state = self.state.clone(); + let state = scrollbar_state.clone(); let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); move |event: &MouseMoveEvent, _, _, cx| { @@ -770,9 +862,7 @@ impl Element for Scrollbar { if state.get().hovered_axis != Some(axis) { notify = true; } - } else if state.get().hovered_axis == Some(axis) - && state.get().hovered_axis.is_some() - { + } else if state.get().hovered_axis == Some(axis) { state.set(state.get().with_hovered(None)); notify = true; } @@ -790,6 +880,9 @@ impl Element for Scrollbar { // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { + // Stop the event propagation to avoid selecting text or other side effects. + cx.stop_propagation(); + // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; @@ -836,10 +929,12 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { + scroll_handle.end_drag(); state.set(state.get().with_unset_drag_pos()); cx.notify(view_id); } diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index ef09230..441296d 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -22,8 +22,8 @@ impl Skeleton { } } - pub fn secondary(mut self, secondary: bool) -> Self { - self.secondary = secondary; + pub fn secondary(mut self) -> Self { + self.secondary = true; self } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index febd958..16c6515 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,11 +1,7 @@ -use std::fmt::{self, Display, Formatter}; - -use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled}; +use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled}; use serde::{Deserialize, Serialize}; use theme::ActiveTheme; -use crate::scroll::{Scrollable, ScrollbarAxis}; - /// Returns a `Div` as horizontal flex layout. pub fn h_flex() -> Div { div().h_flex() @@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } - /// Wraps the element in a ScrollView. - /// - /// Current this is only have a vertical scrollbar. - #[inline] - fn scrollable(self, axis: impl Into) -> Scrollable - where - Self: Element, - { - Scrollable::new(axis, self) - } - font_weight!(font_thin, THIN); font_weight!(font_extralight, EXTRA_LIGHT); font_weight!(font_light, LIGHT); @@ -259,74 +244,6 @@ impl StyleSized for T { } } -pub trait AxisExt { - fn is_horizontal(&self) -> bool; - fn is_vertical(&self) -> bool; -} - -impl AxisExt for Axis { - fn is_horizontal(&self) -> bool { - self == &Axis::Horizontal - } - - fn is_vertical(&self) -> bool { - self == &Axis::Vertical - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Placement { - Top, - Bottom, - Left, - Right, -} - -impl Display for Placement { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Placement::Top => write!(f, "Top"), - Placement::Bottom => write!(f, "Bottom"), - Placement::Left => write!(f, "Left"), - Placement::Right => write!(f, "Right"), - } - } -} - -impl Placement { - pub fn is_horizontal(&self) -> bool { - matches!(self, Placement::Left | Placement::Right) - } - - pub fn is_vertical(&self) -> bool { - matches!(self, Placement::Top | Placement::Bottom) - } - - pub fn axis(&self) -> Axis { - match self { - Placement::Top | Placement::Bottom => Axis::Vertical, - Placement::Left | Placement::Right => Axis::Horizontal, - } - } -} - -/// A enum for defining the side of the element. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Side { - Left, - Right, -} - -impl Side { - pub(crate) fn is_left(&self) -> bool { - matches!(self, Self::Left) - } - - pub(crate) fn is_right(&self) -> bool { - matches!(self, Self::Right) - } -} - /// A trait for defining element that can be collapsed. pub trait Collapsible { fn collapsed(self, collapsed: bool) -> Self;