From 9bee5f2a7796126a940dfa78f1f05d46e23c9bb0 Mon Sep 17 00:00:00 2001 From: reya Date: Wed, 11 Feb 2026 08:55:42 +0700 Subject: [PATCH] redesign the sidebar --- Cargo.lock | 325 ++++++-- crates/coop/src/main.rs | 1 - crates/coop/src/panels/greeter.rs | 14 +- crates/coop/src/{ => sidebar}/command_bar.rs | 4 +- crates/coop/src/sidebar/mod.rs | 658 +++++++++++++--- crates/coop/src/workspace.rs | 52 +- crates/dock/src/stack_panel.rs | 30 +- crates/settings/src/lib.rs | 1 - crates/state/src/lib.rs | 8 +- crates/ui/src/button.rs | 239 +++--- crates/ui/src/divider.rs | 4 +- crates/ui/src/menu/popup_menu.rs | 3 + crates/ui/src/popup_menu.rs | 776 ------------------- crates/ui/src/styled.rs | 14 +- 14 files changed, 962 insertions(+), 1167 deletions(-) rename crates/coop/src/{ => sidebar}/command_bar.rs (99%) delete mode 100644 crates/ui/src/popup_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 6830357..d14b4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,9 +291,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" dependencies = [ "compression-codecs", "compression-core", @@ -501,10 +501,8 @@ dependencies = [ [[package]] name = "async-wsocket" version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +source = "git+https://github.com/shadowylab/async-wsocket?rev=0fed6c9c6aec7393ee0e9cf3933d76914ab427d3#0fed6c9c6aec7393ee0e9cf3933d76914ab427d3" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", @@ -514,6 +512,7 @@ dependencies = [ "tokio-tungstenite", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -1264,7 +1263,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1709,7 +1708,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "proc-macro2", "quote", @@ -1914,7 +1913,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg", ] @@ -2008,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2535,6 +2534,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -2627,7 +2639,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2729,7 +2741,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2740,7 +2752,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "gpui", @@ -2975,7 +2987,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "async-compression", @@ -3000,7 +3012,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3197,6 +3209,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -3497,6 +3515,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3505,15 +3529,15 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" dependencies = [ "arbitrary", "cc", @@ -3756,7 +3780,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "bindgen", @@ -3770,9 +3794,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -3996,7 +4020,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "aes", "base64", @@ -4021,7 +4045,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "async-utility", "futures-core", @@ -4034,7 +4058,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "btreecap", "flatbuffers", @@ -4046,7 +4070,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "nostr", ] @@ -4054,7 +4078,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "indexmap", "lru", @@ -4066,7 +4090,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "async-utility", "flume", @@ -4080,7 +4104,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#9031e3684c661690a4c61865ac11d311456371e7" +source = "git+https://github.com/rust-nostr/nostr#aeee536bdec863afffffe4819150230e20b8ad6b" dependencies = [ "async-utility", "async-wsocket", @@ -4098,9 +4122,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -4111,7 +4135,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4614,7 +4638,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "collections", "serde", @@ -4910,9 +4934,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" dependencies = [ "ar_archive_writer", "cc", @@ -5021,7 +5045,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5274,7 +5298,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "derive_refineable", ] @@ -5373,7 +5397,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "bytes", @@ -5428,7 +5452,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "arrayvec", "log", @@ -5541,7 +5565,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5656,9 +5680,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salsa20" @@ -5690,7 +5714,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "async-task", "backtrace", @@ -6176,9 +6200,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" dependencies = [ "cc", "cfg-if", @@ -6305,7 +6329,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "arrayvec", "log", @@ -6316,15 +6340,15 @@ dependencies = [ [[package]] name = "sval" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502b8906c4736190684646827fbab1e954357dfe541013bbd7994d033d53a1ca" +checksum = "c1aaf178a50bbdd86043fce9bf0a5867007d9b382db89d1c96ccae4601ff1ff9" [[package]] name = "sval_buffer" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4b854348b15b6c441bdd27ce9053569b016a0723eab2d015b1fd8e6abe4f708" +checksum = "f89273e48f03807ebf51c4d81c52f28d35ffa18a593edf97e041b52de143df89" dependencies = [ "sval", "sval_ref", @@ -6332,18 +6356,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bd9e8b74410ddad37c6962587c5f9801a2caadba9e11f3f916ee3f31ae4a1f" +checksum = "0430f4e18e7eba21a49d10d25a8dec3ce0e044af40b162347e99a8e3c3ced864" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe17b8deb33a9441280b4266c2d257e166bafbaea6e66b4b34ca139c91766d9" +checksum = "835f51b9d7331b9d7fc48fc716c02306fa88c4a076b1573531910c91a525882d" dependencies = [ "itoa", "ryu", @@ -6352,9 +6376,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854addb048a5bafb1f496c98e0ab5b9b581c3843f03ca07c034ae110d3b7c623" +checksum = "13cbfe3ef406ee2366e7e8ab3678426362085fa9eaedf28cb878a967159dced3" dependencies = [ "itoa", "ryu", @@ -6363,9 +6387,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96cf068f482108ff44ae8013477cb047a1665d5f1a635ad7cf79582c1845dce9" +checksum = "8b20358af4af787c34321a86618c3cae12eabdd0e9df22cd9dd2c6834214c518" dependencies = [ "sval", "sval_buffer", @@ -6374,18 +6398,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed02126365ffe5ab8faa0abd9be54fbe68d03d607cd623725b0a71541f8aaa6f" +checksum = "fb5e500f8eb2efa84f75e7090f7fc43f621b9f8b6cde571c635b3855f97b332a" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a263383c6aa2076c4ef6011d3bae1b356edf6ea2613e3d8e8ebaa7b57dd707d5" +checksum = "ca2032ae39b11dcc6c18d5fbc50a661ea191cac96484c59ccf49b002261ca2c1" dependencies = [ "serde_core", "sval", @@ -6557,15 +6581,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6812,9 +6836,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -6853,9 +6877,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", @@ -6912,9 +6936,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" dependencies = [ "winnow", ] @@ -7051,9 +7075,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -7140,9 +7164,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-linebreak" @@ -7189,6 +7213,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -7266,7 +7296,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "async-fs", @@ -7304,7 +7334,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "perf", "quote", @@ -7449,6 +7479,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -7514,6 +7553,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -7527,6 +7588,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -7647,9 +7720,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" dependencies = [ "core-foundation 0.10.0", "jni", @@ -7748,7 +7821,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8349,6 +8422,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -8780,7 +8935,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "anyhow", "chrono", @@ -8790,14 +8945,14 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" dependencies = [ "tracing", "tracing-subscriber", @@ -8808,7 +8963,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7cb4d75139b39efb0d1d1e90faf7480d0f1dc734" +source = "git+https://github.com/zed-industries/zed#6cd7586c161b579ba7c1ecad7dea1b95ca3dd239" [[package]] name = "zune-core" diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 060910c..02df297 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -12,7 +12,6 @@ use ui::Root; use crate::actions::Quit; mod actions; -mod command_bar; mod dialogs; mod panels; mod sidebar; diff --git a/crates/coop/src/panels/greeter.rs b/crates/coop/src/panels/greeter.rs index 9b58edd..7b0dce1 100644 --- a/crates/coop/src/panels/greeter.rs +++ b/crates/coop/src/panels/greeter.rs @@ -154,7 +154,7 @@ impl Render for GreeterPanel { .label("Set up relay list") .ghost() .small() - .no_center() + .justify_start() .on_click(move |_ev, window, cx| { Workspace::add_panel( relay_list::init(window, cx), @@ -172,7 +172,7 @@ impl Render for GreeterPanel { .label("Set up messaging relays") .ghost() .small() - .no_center() + .justify_start() .on_click(move |_ev, window, cx| { Workspace::add_panel( messaging_relays::init(window, cx), @@ -211,7 +211,7 @@ impl Render for GreeterPanel { .label("Connect account via Nostr Connect") .ghost() .small() - .no_center() + .justify_start() .on_click(move |_ev, window, cx| { Workspace::add_panel( connect::init(window, cx), @@ -227,7 +227,7 @@ impl Render for GreeterPanel { .label("Import a secret key or bunker") .ghost() .small() - .no_center() + .justify_start() .on_click(move |_ev, window, cx| { Workspace::add_panel( import::init(window, cx), @@ -264,7 +264,7 @@ impl Render for GreeterPanel { .label("Backup account") .ghost() .small() - .no_center(), + .justify_start(), ) .child( Button::new("profile") @@ -272,7 +272,7 @@ impl Render for GreeterPanel { .label("Update profile") .ghost() .small() - .no_center() + .justify_start() .on_click(cx.listener(move |this, _ev, window, cx| { this.add_profile_panel(window, cx) })), @@ -283,7 +283,7 @@ impl Render for GreeterPanel { .label("Invite friends") .ghost() .small() - .no_center(), + .justify_start(), ), ), ), diff --git a/crates/coop/src/command_bar.rs b/crates/coop/src/sidebar/command_bar.rs similarity index 99% rename from crates/coop/src/command_bar.rs rename to crates/coop/src/sidebar/command_bar.rs index 96ab1cc..09962d2 100644 --- a/crates/coop/src/command_bar.rs +++ b/crates/coop/src/sidebar/command_bar.rs @@ -438,9 +438,9 @@ impl Render for CommandBar { .w_full() .child( TextInput::new(&self.find_input) - .appearance(true) + .appearance(false) .bordered(false) - .xsmall() + .small() .text_xs() .when(!self.find_input.read(cx).loading, |this| { this.suffix( diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index 072b0b6..041d181 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -1,23 +1,38 @@ +use std::collections::HashSet; use std::ops::Range; +use std::time::Duration; -use chat::{ChatEvent, ChatRegistry, RoomKind}; -use common::RenderedTimestamp; +use anyhow::Error; +use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; +use common::{DebouncedDelay, RenderedTimestamp}; use dock::panel::{Panel, PanelEvent}; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, - Subscription, Window, + div, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache, + SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, }; use list_item::RoomListItem; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; +use state::{NostrRegistry, FIND_DELAY}; use theme::{ActiveTheme, TABBAR_HEIGHT}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; +use ui::divider::Divider; use ui::indicator::Indicator; -use ui::{h_flex, v_flex, IconName, Selectable, Sizable, StyledExt}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; +use ui::{ + h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, +}; mod list_item; +const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; + pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Sidebar::new(window, cx)) } @@ -30,12 +45,42 @@ pub struct Sidebar { /// Image cache image_cache: Entity, + /// Find input state + find_input: Entity, + + /// Debounced delay for find input + find_debouncer: DebouncedDelay, + + /// Whether a search is in progress + finding: bool, + + /// Whether the find input is focused + find_focused: bool, + + /// Find results + find_results: Entity>>, + + /// Async find operation + find_task: Option>>, + + /// Whether there are search results + has_search: bool, + /// Whether there are new chat requests new_requests: bool, + /// Selected public keys + selected_pkeys: Entity>, + /// Chatroom filter filter: Entity, + /// User's contacts + contact_list: Entity>>, + + /// Async tasks + tasks: SmallVec<[Task<()>; 1]>, + /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, } @@ -44,9 +89,49 @@ impl Sidebar { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let filter = cx.new(|_| RoomKind::Ongoing); + let contact_list = cx.new(|_| None); + let selected_pkeys = cx.new(|_| HashSet::new()); + let find_results = cx.new(|_| None); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(INPUT_PLACEHOLDER) + .clean_on_escape() + }); let mut subscriptions = smallvec![]; + subscriptions.push( + // Subscribe to find input events + cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { + let delay = Duration::from_millis(FIND_DELAY); + + match event { + InputEvent::PressEnter { .. } => { + this.search(window, cx); + } + InputEvent::Change => { + if state.read(cx).value().is_empty() { + // Clear results when input is empty + this.reset(window, cx); + } else { + // Run debounced search + this.find_debouncer + .fire_new(delay, window, cx, |this, window, cx| { + this.debounced_search(window, cx) + }); + } + } + InputEvent::Focus => { + this.set_input_focus(cx); + this.get_contact_list(window, cx); + } + InputEvent::Blur => { + this.set_input_focus(cx); + } + }; + }), + ); + subscriptions.push( // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { @@ -61,12 +146,193 @@ impl Sidebar { name: "Sidebar".into(), focus_handle: cx.focus_handle(), image_cache: RetainAllImageCache::new(cx), + find_input, + find_debouncer: DebouncedDelay::new(), + find_results, + find_task: None, + find_focused: false, + finding: false, + has_search: false, new_requests: false, + contact_list, + selected_pkeys, filter, + tasks: smallvec![], _subscriptions: subscriptions, } } + /// Get the contact list. + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).get_contact_list(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.set_contact_list(contacts, cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; + })); + } + + /// Set the contact list with new contacts. + fn set_contact_list(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + *this = Some(contacts.into_iter().collect()); + cx.notify(); + }); + } + + /// Trigger the debounced search + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, window, cx| { + this.search(window, cx); + }) + .ok(); + }) + } + + /// 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(); + + // Return if the query is empty + if query.is_empty() { + return; + } + + // Block the input until the search completes + self.set_finding(true, window, cx); + + 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); + this.set_finding(false, window, cx); + })?; + + Ok(()) + })); + } + + fn set_results(&mut self, results: Vec, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(results); + cx.notify(); + }); + } + + fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_disabled(status, cx); + this.set_loading(status, cx); + }); + // Set the search status + self.finding = status; + cx.notify(); + } + + fn set_input_focus(&mut self, cx: &mut Context) { + self.find_focused = !self.find_focused; + cx.notify(); + } + + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + // Clear all search results + self.find_results.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; + cx.notify(); + } + + /// Select a public key in the sidebar. + fn select(&mut self, pkey: PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(&pkey) { + this.remove(&pkey); + } else { + this.insert(pkey); + } + cx.notify(); + }); + } + + /// Check if a public key is selected in the sidebar. + fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(&pkey) + } + + /// Get all selected public keys in the sidebar. + fn selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + /// Create a new room + fn create_room(&mut self, window: &mut Window, cx: &mut Context) { + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + + let nostr = NostrRegistry::global(cx); + let signer_pkey = nostr.read(cx).signer_pkey(cx); + + // Get all selected public keys + let receivers = self.selected(cx); + + let task: Task> = cx.spawn_in(window, async move |_this, cx| { + let public_key = signer_pkey.await?; + + async_chat.update_in(cx, |this, window, cx| { + let room = cx.new(|_| Room::new(public_key, receivers)); + this.emit_room(room.downgrade(), cx); + + window.close_modal(cx); + })?; + + Ok(()) + }); + + task.detach(); + } + /// Get the active filter. fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { self.filter.read(cx) == kind @@ -111,6 +377,67 @@ impl Sidebar { }) .collect() } + + fn render_contacts(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + + let Some(contacts) = self.contact_list.read(cx) else { + return vec![]; + }; + + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let profile = persons.read(cx).get(item, cx); + let pkey = item.to_owned(); + let id = range.start + ix; + + h_flex() + .id(id) + .h_8() + .w_full() + .px_1() + .gap_2() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .text_sm() + .child(profile.name()) + .when(self.is_selected(pkey, cx), |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.select(pkey, cx); + })) + .into_any_element() + }) + .collect() + } } impl Panel for Sidebar { @@ -133,89 +460,124 @@ impl Render for Sidebar { let loading = chat.read(cx).loading(); let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); + // Whether the find panel should be shown + let show_find_panel = self.has_search || self.find_focused; + + // Set button label based on total selected users + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; + v_flex() .image_cache(self.image_cache.clone()) .size_full() .relative() - .gap_2() .child( h_flex() .h(TABBAR_HEIGHT) - .w_full() .border_b_1() .border_color(cx.theme().border) .child( - h_flex() - .flex_1() - .h_full() - .gap_2() - .p_2() - .justify_center() - .child( - Button::new("all") - .map(|this| { - if self.current_filter(&RoomKind::Ongoing, cx) { - this.icon(IconName::InboxFill) - } else { - this.icon(IconName::Inbox) - } - }) - .label("Inbox") - .tooltip("All ongoing conversations") - .xsmall() - .bold() - .ghost() - .flex_1() - .rounded_none() - .selected(self.current_filter(&RoomKind::Ongoing, cx)) - .on_click(cx.listener(|this, _, _, cx| { - this.set_filter(RoomKind::Ongoing, cx); - })), - ) - .child( - Button::new("requests") - .map(|this| { - if self.current_filter(&RoomKind::Request, cx) { - this.icon(IconName::FistbumpFill) - } else { - this.icon(IconName::Fistbump) - } - }) - .label("Requests") - .tooltip("Incoming new conversations") - .xsmall() - .bold() - .ghost() - .flex_1() - .rounded_none() - .selected(!self.current_filter(&RoomKind::Ongoing, cx)) - .when(self.new_requests, |this| { - this.child( - div().size_1().rounded_full().bg(cx.theme().cursor), - ) - }) - .on_click(cx.listener(|this, _, _, cx| { - this.set_filter(RoomKind::default(), cx); - })), - ), - ) - .child( - h_flex() - .h_full() - .px_2() - .border_l_1() - .border_color(cx.theme().border) - .child( - Button::new("option") - .icon(IconName::Ellipsis) - .small() - .ghost(), - ), + TextInput::new(&self.find_input) + .appearance(false) + .bordered(false) + .small() + .text_xs() + .when(!self.find_input.read(cx).loading, |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }), ), ) - .when(!loading && total_rooms == 0, |this| { + .child( + h_flex() + .h(TABBAR_HEIGHT) + .justify_center() + .border_b_1() + .border_color(cx.theme().border) + .when(show_find_panel, |this| { + this.child( + Button::new("search-results") + .icon(IconName::Search) + .label("Search") + .tooltip("All search results") + .small() + .underline() + .ghost() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .selected(true), + ) + }) + .child( + Button::new("all") + .map(|this| { + if self.current_filter(&RoomKind::Ongoing, cx) { + this.icon(IconName::InboxFill) + } else { + this.icon(IconName::Inbox) + } + }) + .when(!show_find_panel, |this| this.label("Inbox")) + .tooltip("All ongoing conversations") + .small() + .underline() + .ghost() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .disabled(show_find_panel) + .selected( + !show_find_panel && self.current_filter(&RoomKind::Ongoing, cx), + ) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.set_filter(RoomKind::Ongoing, cx); + })), + ) + .child(Divider::vertical()) + .child( + Button::new("requests") + .map(|this| { + if self.current_filter(&RoomKind::Request, cx) { + this.icon(IconName::FistbumpFill) + } else { + this.icon(IconName::Fistbump) + } + }) + .when(!show_find_panel, |this| this.label("Requests")) + .tooltip("Incoming new conversations") + .small() + .ghost() + .underline() + .font_semibold() + .rounded_none() + .h_full() + .flex_1() + .disabled(show_find_panel) + .selected( + !show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx), + ) + .when(self.new_requests, |this| { + this.child(div().size_1().rounded_full().bg(cx.theme().cursor)) + }) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.set_filter(RoomKind::default(), cx); + })), + ), + ) + .when(!show_find_panel && !loading && total_rooms == 0, |this| { this.child( - div().px_2p5().child(deferred( + div().mt_2().px_2().child( v_flex() .p_3() .h_24() @@ -238,47 +600,131 @@ impl Render for Sidebar { "Start a conversation with someone to get started.", ), )), - )), + ), ) }) .child( v_flex() + .h_full() .px_1p5() - .w_full() + .mt_2() .flex_1() .gap_1() .overflow_y_hidden() - .child( - uniform_list( - "rooms", - total_rooms, - cx.processor(|this, range, _window, cx| { - this.render_list_items(range, cx) - }), - ) - .h_full(), - ) - .when(loading, |this| { + .when(show_find_panel, |this| { + this.gap_2() + .when_some(self.find_results.read(cx).as_ref(), |this, results| { + this.child( + v_flex() + .gap_2() + .flex_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Results")), + ) + .child( + uniform_list( + "rooms", + results.len(), + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { + this.child( + v_flex() + .gap_2() + .flex_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Suggestions")), + ) + .child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(move |this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + }) + .when(!show_find_panel, |this| { this.child( - div().absolute().top_2().left_0().w_full().px_8().child( - h_flex() - .gap_2() - .w_full() - .h_9() - .justify_center() - .bg(cx.theme().background.opacity(0.85)) - .border_color(cx.theme().border_disabled) - .border_1() - .when(cx.theme().shadow, |this| this.shadow_sm()) - .rounded_full() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Indicator::new().small().color(cx.theme().icon_accent)) - .child(SharedString::from("Getting messages...")), - ), + uniform_list( + "rooms", + total_rooms, + cx.processor(|this, range, _window, cx| { + this.render_list_items(range, cx) + }), + ) + .flex_1() + .h_full(), ) }), ) + .when(!self.selected_pkeys.read(cx).is_empty(), |this| { + this.child( + div() + .absolute() + .bottom_0() + .left_0() + .h_9() + .w_full() + .px_2() + .child( + Button::new("create") + .label(button_label) + .primary() + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.create_room(window, cx); + })), + ), + ) + }) + .when(loading, |this| { + this.child( + div() + .absolute() + .bottom_2() + .left_0() + .h_9() + .w_full() + .px_8() + .child( + h_flex() + .gap_2() + .w_full() + .h_9() + .justify_center() + .bg(cx.theme().background.opacity(0.85)) + .border_color(cx.theme().border_disabled) + .border_1() + .when(cx.theme().shadow, |this| this.shadow_sm()) + .rounded_full() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Indicator::new().small().color(cx.theme().icon_accent)) + .child(SharedString::from("Getting messages...")), + ), + ) + }) } } diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 4d32e2b..4d82554 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -13,12 +13,13 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; use state::NostrRegistry; -use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; +use theme::{SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; use titlebar::TitleBar; use ui::avatar::Avatar; -use ui::{h_flex, v_flex, Icon, IconName, Root, Sizable, WindowExtension}; +use ui::button::{Button, ButtonVariants}; +use ui::popup_menu::PopupMenuExt; +use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; -use crate::command_bar::CommandBar; use crate::panels::greeter; use crate::sidebar; @@ -33,9 +34,6 @@ pub struct Workspace { /// App's Dock Area dock: Entity, - /// App's Command Bar - command_bar: Entity, - /// Current User current_user: Entity>, @@ -45,22 +43,16 @@ pub struct Workspace { impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { + let titlebar = cx.new(|_| TitleBar::new()); + let dock = + cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); + let chat = ChatRegistry::global(cx); let current_user = cx.new(|_| None); let nostr = NostrRegistry::global(cx); let nip65_state = nostr.read(cx).nip65_state(); - // Titlebar - let titlebar = cx.new(|_| TitleBar::new()); - - // Command bar - let command_bar = cx.new(|cx| CommandBar::new(window, cx)); - - // Dock - let dock = - cx.new(|cx| DockArea::new(window, cx).panel_style(dock::panel::PanelStyle::TabBar)); - let mut subscriptions = smallvec![]; subscriptions.push( @@ -124,7 +116,6 @@ impl Workspace { Self { titlebar, dock, - command_bar, current_user, _subscriptions: subscriptions, } @@ -213,7 +204,8 @@ impl Workspace { fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { h_flex() .h(TITLEBAR_HEIGHT) - .flex_1() + .w(SIDEBAR_WIDTH) + .flex_shrink_0() .justify_between() .gap_2() .when_some(self.current_user.read(cx).as_ref(), |this, public_key| { @@ -221,24 +213,30 @@ impl Workspace { let profile = persons.read(cx).get(public_key, cx); this.child( - h_flex() - .gap_0p5() + Button::new("current-user") .child(Avatar::new(profile.avatar()).size(rems(1.25))) - .child( - Icon::new(IconName::ChevronDown) - .small() - .text_color(cx.theme().text_muted), - ), + .small() + .caret() + .compact() + .transparent() + .popup_menu(move |this, _window, _cx| { + this.label(profile.name()) + .separator() + .menu("Profile", Box::new(ClosePanel)) + .menu("Backup", Box::new(ClosePanel)) + .menu("Themes", Box::new(ClosePanel)) + .menu("Settings", Box::new(ClosePanel)) + }), ) }) } fn titlebar_center(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { - h_flex().flex_1().w_full().child(self.command_bar.clone()) + h_flex().h(TITLEBAR_HEIGHT).flex_1() } fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { - h_flex().flex_1() + h_flex().h(TITLEBAR_HEIGHT).w(SIDEBAR_WIDTH).flex_shrink_0() } } diff --git a/crates/dock/src/stack_panel.rs b/crates/dock/src/stack_panel.rs index 2a2dd19..4a684bd 100644 --- a/crates/dock/src/stack_panel.rs +++ b/crates/dock/src/stack_panel.rs @@ -65,7 +65,7 @@ impl StackPanel { } /// Return true if self or parent only have last panel. - pub(super) fn is_last_panel(&self, cx: &App) -> bool { + pub fn is_last_panel(&self, cx: &App) -> bool { if self.panels.len() > 1 { return false; } @@ -79,12 +79,12 @@ impl StackPanel { true } - pub(super) fn panels_len(&self) -> usize { + pub fn panels_len(&self) -> usize { self.panels.len() } /// Return the index of the panel. - pub(crate) fn index_of_panel(&self, panel: Arc) -> Option { + pub fn index_of_panel(&self, panel: Arc) -> Option { self.panels.iter().position(|p| p == &panel) } @@ -253,11 +253,12 @@ impl StackPanel { }); cx.emit(PanelEvent::LayoutChanged); + self.remove_self_if_empty(window, cx); } /// Replace the old panel with the new panel at same index. - pub(super) fn replace_panel( + pub fn replace_panel( &mut self, old_panel: Arc, new_panel: Entity, @@ -266,16 +267,15 @@ impl StackPanel { ) { if let Some(ix) = self.index_of_panel(old_panel.clone()) { self.panels[ix] = Arc::new(new_panel.clone()); - let panel_state = ResizablePanelState::default(); self.state.update(cx, |state, cx| { - state.replace_panel(ix, panel_state, cx); + state.replace_panel(ix, ResizablePanelState::default(), cx); }); cx.emit(PanelEvent::LayoutChanged); } } /// If children is empty, remove self from parent view. - pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context) { + pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context) { if self.is_root() { return; } @@ -296,11 +296,7 @@ impl StackPanel { } /// Find the first top left in the stack. - pub(super) fn left_top_tab_panel( - &self, - check_parent: bool, - cx: &App, - ) -> Option> { + pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option> { if check_parent { if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) { @@ -324,11 +320,7 @@ impl StackPanel { } /// Find the first top right in the stack. - pub(super) fn right_top_tab_panel( - &self, - check_parent: bool, - cx: &App, - ) -> Option> { + pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option> { if check_parent { if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) { if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) { @@ -357,7 +349,7 @@ impl StackPanel { } /// Remove all panels from the stack. - pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { + pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context) { self.panels.clear(); self.state.update(cx, |state, cx| { state.clear(); @@ -366,7 +358,7 @@ impl StackPanel { } /// Change the axis of the stack panel. - pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { + pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context) { self.axis = axis; cx.notify(); } diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index adfdf84..0aa9bcf 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -241,7 +241,6 @@ impl AppSettings { // Save event to the local database only client.database().save_event(&event).await?; - log::info!("Settings saved successfully"); Ok(()) })); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 06e1147..cf3b9e9 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::time::Duration; @@ -576,17 +576,15 @@ impl NostrRegistry { } /// Get contact list for the current user - pub fn get_contact_list(&self, cx: &App) -> Task, Error>> { + pub fn get_contact_list(&self, cx: &App) -> Task, Error>> { let client = self.client(); cx.background_spawn(async move { let signer = client.signer().context("Signer not found")?; let public_key = signer.get_public_key().await?; - let contacts = client.database().contacts_public_keys(public_key).await?; - let results = contacts.into_iter().collect(); - Ok(results) + Ok(contacts) }) } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 0c151fe..e15b2ea 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -10,7 +10,7 @@ use theme::ActiveTheme; use crate::indicator::Indicator; use crate::tooltip::Tooltip; -use crate::{h_flex, Disableable, Icon, Selectable, Sizable, Size, StyledExt}; +use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt}; #[derive(Clone, Copy, PartialEq, Eq)] pub struct ButtonCustomVariant { @@ -20,50 +20,6 @@ pub struct ButtonCustomVariant { active: Hsla, } -pub trait ButtonVariants: Sized { - fn with_variant(self, variant: ButtonVariant) -> Self; - - /// With the primary style for the Button. - fn primary(self) -> Self { - self.with_variant(ButtonVariant::Primary) - } - - /// With the secondary style for the Button. - fn secondary(self) -> Self { - self.with_variant(ButtonVariant::Secondary) - } - - /// With the danger style for the Button. - fn danger(self) -> Self { - self.with_variant(ButtonVariant::Danger) - } - - /// With the warning style for the Button. - fn warning(self) -> Self { - self.with_variant(ButtonVariant::Warning) - } - - /// With the ghost style for the Button. - fn ghost(self) -> Self { - self.with_variant(ButtonVariant::Ghost { alt: false }) - } - - /// With the ghost style for the Button. - fn ghost_alt(self) -> Self { - self.with_variant(ButtonVariant::Ghost { alt: true }) - } - - /// With the transparent style for the Button. - fn transparent(self) -> Self { - self.with_variant(ButtonVariant::Transparent) - } - - /// With the custom style for the Button. - fn custom(self, style: ButtonCustomVariant) -> Self { - self.with_variant(ButtonVariant::Custom(style)) - } -} - impl ButtonCustomVariant { pub fn new(_window: &Window, cx: &App) -> Self { Self { @@ -110,6 +66,50 @@ pub enum ButtonVariant { Custom(ButtonCustomVariant), } +pub trait ButtonVariants: Sized { + fn with_variant(self, variant: ButtonVariant) -> Self; + + /// With the primary style for the Button. + fn primary(self) -> Self { + self.with_variant(ButtonVariant::Primary) + } + + /// With the secondary style for the Button. + fn secondary(self) -> Self { + self.with_variant(ButtonVariant::Secondary) + } + + /// With the danger style for the Button. + fn danger(self) -> Self { + self.with_variant(ButtonVariant::Danger) + } + + /// With the warning style for the Button. + fn warning(self) -> Self { + self.with_variant(ButtonVariant::Warning) + } + + /// With the ghost style for the Button. + fn ghost(self) -> Self { + self.with_variant(ButtonVariant::Ghost { alt: false }) + } + + /// With the ghost style for the Button. + fn ghost_alt(self) -> Self { + self.with_variant(ButtonVariant::Ghost { alt: true }) + } + + /// With the transparent style for the Button. + fn transparent(self) -> Self { + self.with_variant(ButtonVariant::Transparent) + } + + /// With the custom style for the Button. + fn custom(self, style: ButtonCustomVariant) -> Self { + self.with_variant(ButtonVariant::Custom(style)) + } +} + /// A Button element. #[derive(IntoElement)] #[allow(clippy::type_complexity)] @@ -124,17 +124,15 @@ pub struct Button { children: Vec, variant: ButtonVariant, - center: bool, - rounded: bool, size: Size, disabled: bool, - reverse: bool, - bold: bool, - cta: bool, - loading: bool, - loading_icon: Option, + + rounded: bool, + compact: bool, + underline: bool, + caret: bool, on_click: Option>, on_hover: Option>, @@ -161,21 +159,19 @@ impl Button { style: StyleRefinement::default(), icon: None, label: None, + variant: ButtonVariant::default(), disabled: false, selected: false, - variant: ButtonVariant::default(), + underline: false, + compact: false, + caret: false, rounded: false, size: Size::Medium, tooltip: None, on_click: None, on_hover: None, loading: false, - reverse: false, - center: true, - bold: false, - cta: false, children: Vec::new(), - loading_icon: None, tab_index: 0, tab_stop: true, } @@ -211,33 +207,21 @@ impl Button { self } - /// Set reverse the position between icon and label. - pub fn reverse(mut self) -> Self { - self.reverse = true; + /// Set true to make the button compact (no padding). + pub fn compact(mut self) -> Self { + self.compact = true; self } - /// Set bold the button (label will be use the semi-bold font). - pub fn bold(mut self) -> Self { - self.bold = true; + /// Set true to show the caret indicator. + pub fn caret(mut self) -> Self { + self.caret = true; self } - /// Disable centering the button's content. - pub fn no_center(mut self) -> Self { - self.center = false; - self - } - - /// Set the cta style of the button. - pub fn cta(mut self) -> Self { - self.cta = true; - self - } - - /// Set the loading icon of the button. - pub fn loading_icon(mut self, icon: impl Into) -> Self { - self.loading_icon = Some(icon.into()); + /// Set true to show the underline indicator. + pub fn underline(mut self) -> Self { + self.underline = true; self } @@ -346,7 +330,7 @@ impl RenderOnce for Button { }; let focus_handle = window - .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) + .use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle()) .read(cx) .clone(); @@ -358,10 +342,11 @@ impl RenderOnce for Button { .tab_stop(self.tab_stop), ) }) + .relative() .flex_shrink_0() .flex() .items_center() - .when(self.center, |this| this.justify_center()) + .justify_center() .cursor_default() .overflow_hidden() .refine_style(&self.style) @@ -369,39 +354,15 @@ impl RenderOnce for Button { false => this.rounded(cx.theme().radius), true => this.rounded_full(), }) - .map(|this| { + .when(!self.compact, |this| { if self.label.is_none() && self.children.is_empty() { // Icon Button match self.size { Size::Size(px) => this.size(px), - Size::XSmall => { - if self.cta { - this.w_10().h_5() - } else { - this.size_5() - } - } - Size::Small => { - if self.cta { - this.w_12().h_6() - } else { - this.size_6() - } - } - Size::Medium => { - if self.cta { - this.w_12().h_7() - } else { - this.size_7() - } - } - _ => { - if self.cta { - this.w_16().h_9() - } else { - this.size_9() - } - } + Size::XSmall => this.size_5(), + Size::Small => this.size_6(), + Size::Medium => this.size_7(), + _ => this.size_9(), } } else { // Normal Button @@ -410,8 +371,6 @@ impl RenderOnce for Button { Size::XSmall => { if self.icon.is_some() { this.h_6().pl_2().pr_2p5() - } else if self.cta { - this.h_6().px_4() } else { this.h_6().px_2() } @@ -419,8 +378,6 @@ impl RenderOnce for Button { Size::Small => { if self.icon.is_some() { this.h_7().pl_2().pr_2p5() - } else if self.cta { - this.h_7().px_4() } else { this.h_7().px_2() } @@ -442,13 +399,27 @@ impl RenderOnce for Button { } } }) - .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { + .refine_style(&self.style) + .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { + // Stop handle any click event when disabled. + // To avoid handle dropdown menu open when button is disabled. + if self.disabled { + cx.stop_propagation(); + return; + } // Avoid focus on mouse down. window.prevent_default(); }) - .when_some(self.on_click.filter(|_| clickable), |this, on_click| { + .when_some(self.on_click, |this, on_click| { this.on_click(move |event, window, cx| { - (on_click)(event, window, cx); + // Stop handle any click event when disabled. + // To avoid handle dropdown menu open when button is disabled. + if !clickable { + cx.stop_propagation(); + return; + } + + on_click(event, window, cx); }) }) .when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| { @@ -459,7 +430,6 @@ impl RenderOnce for Button { .child({ h_flex() .id("label") - .when(self.reverse, |this| this.flex_row_reverse()) .justify_center() .map(|this| match self.size { Size::XSmall => this.text_xs().gap_1(), @@ -471,22 +441,18 @@ impl RenderOnce for Button { this.child(icon.with_size(icon_size)) }) }) - .when(self.loading, |this| { - this.child( - Indicator::new() - .when_some(self.loading_icon, |this, icon| this.icon(icon)), - ) - }) + .when(self.loading, |this| this.child(Indicator::new())) .when_some(self.label, |this, label| { - this.child( - div() - .flex_none() - .line_height(relative(1.)) - .child(label) - .when(self.bold, |this| this.font_semibold()), - ) + this.child(div().flex_none().line_height(relative(1.)).child(label)) }) .children(self.children) + .when(self.caret, |this| { + this.justify_between().gap_0p5().child( + Icon::new(IconName::ChevronDown) + .small() + .text_color(cx.theme().text_muted), + ) + }) }) .text_color(normal_style.fg) .when(!self.disabled && !self.selected, |this| { @@ -504,6 +470,17 @@ impl RenderOnce for Button { let selected_style = style.selected(cx); this.bg(selected_style.bg).text_color(selected_style.fg) }) + .when(self.selected && self.underline, |this| { + this.child( + div() + .absolute() + .bottom_0() + .left_0() + .h_px() + .w_full() + .bg(cx.theme().element_background), + ) + }) .when(self.disabled, |this| { let disabled_style = style.disabled(cx); this.cursor_not_allowed() diff --git a/crates/ui/src/divider.rs b/crates/ui/src/divider.rs index 4086dce..45aa100 100644 --- a/crates/ui/src/divider.rs +++ b/crates/ui/src/divider.rs @@ -61,8 +61,8 @@ impl RenderOnce for Divider { .absolute() .rounded_full() .map(|this| match self.axis { - Axis::Vertical => this.w(px(2.)).h_full(), - Axis::Horizontal => this.h(px(2.)).w_full(), + Axis::Vertical => this.w(px(1.)).h_full(), + Axis::Horizontal => this.h(px(1.)).w_full(), }) .bg(self.color.unwrap_or(cx.theme().border_variant)), ) diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index cf3105e..a9c8d75 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -58,6 +58,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + }) } } + impl PopupMenuExt for Button {} #[allow(clippy::type_complexity)] @@ -1074,7 +1075,9 @@ impl PopupMenu { } impl FluentBuilder for PopupMenu {} + impl EventEmitter for PopupMenu {} + impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs deleted file mode 100644 index a12a30c..0000000 --- a/crates/ui/src/popup_menu.rs +++ /dev/null @@ -1,776 +0,0 @@ -use std::ops::Deref; -use std::rc::Rc; - -use gpui::prelude::FluentBuilder; -use gpui::{ - actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke, - Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, - ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, - Window, -}; -use theme::ActiveTheme; - -use crate::button::Button; -use crate::list::ListItem; -use crate::popover::Popover; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{h_flex, v_flex, Icon, IconName, Selectable, Sizable as _, StyledExt}; - -actions!( - menu, - [ - /// Trigger confirm action when user presses enter button - Confirm, - /// Trigger dismiss action when user presses escape button - Dismiss, - /// Select the next item when user presses up button - SelectNext, - /// Select the previous item when user preses down button - SelectPrev - ] -); - -const ITEM_HEIGHT: Pixels = px(26.); - -pub fn init(cx: &mut App) { - let context = Some("PopupMenu"); - - cx.bind_keys([ - KeyBinding::new("enter", Confirm, context), - KeyBinding::new("escape", Dismiss, context), - KeyBinding::new("up", SelectPrev, context), - KeyBinding::new("down", SelectNext, context), - ]); -} - -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 {} - -enum PopupMenuItem { - Title(SharedString), - Separator, - Item { - icon: Option, - label: SharedString, - action: Option>, - #[allow(clippy::type_complexity)] - handler: Rc, - }, - ElementItem { - #[allow(clippy::type_complexity)] - render: Box AnyElement + 'static>, - #[allow(clippy::type_complexity)] - handler: Rc, - }, - Submenu { - icon: Option, - label: SharedString, - menu: Entity, - }, -} - -impl PopupMenuItem { - fn is_clickable(&self) -> bool { - !matches!(self, PopupMenuItem::Separator) - } - - fn is_separator(&self) -> bool { - matches!(self, PopupMenuItem::Separator) - } - - fn has_icon(&self) -> bool { - matches!(self, PopupMenuItem::Item { icon: Some(_), .. }) - } -} - -pub struct PopupMenu { - /// The parent menu of this menu, if this is a submenu - parent_menu: Option>, - focus_handle: FocusHandle, - menu_items: Vec, - has_icon: bool, - selected_index: Option, - min_width: Pixels, - max_width: Pixels, - hovered_menu_ix: Option, - bounds: Bounds, - - scrollable: bool, - scroll_handle: ScrollHandle, - scroll_state: ScrollbarState, - - action_focus_handle: Option, - #[allow(dead_code)] - subscriptions: Vec, -} - -impl PopupMenu { - pub fn build( - window: &mut Window, - cx: &mut App, - f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, - ) -> Entity { - cx.new(|cx| { - let focus_handle = cx.focus_handle(); - let subscriptions = - vec![ - cx.on_blur(&focus_handle, window, |this: &mut PopupMenu, window, cx| { - this.dismiss(&Dismiss, window, cx) - }), - ]; - let menu = Self { - focus_handle, - action_focus_handle: None, - parent_menu: None, - menu_items: Vec::new(), - selected_index: None, - min_width: px(120.), - max_width: px(500.), - has_icon: false, - hovered_menu_ix: None, - bounds: Bounds::default(), - scrollable: false, - scroll_handle: ScrollHandle::default(), - scroll_state: ScrollbarState::default(), - subscriptions, - }; - - f(menu, window, cx) - }) - } - - /// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action - pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { - self.action_focus_handle = Some(focus_handle.clone()); - self - } - - /// Set min width of the popup menu, default is 120px - pub fn min_w(mut self, width: impl Into) -> Self { - self.min_width = width.into(); - self - } - - /// Set max width of the popup menu, default is 500px - pub fn max_w(mut self, width: impl Into) -> Self { - self.max_width = width.into(); - 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; - self - } - - /// Add Menu Item - pub fn menu(mut self, label: impl Into, action: Box) -> Self { - self.add_menu_item(label, None, action); - self - } - - /// Add Menu to open link - pub fn link(mut self, label: impl Into, href: impl Into) -> Self { - let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: None, - label: label.into(), - action: None, - handler: Rc::new(move |_window, cx| cx.open_url(&href)), - }); - self - } - - /// Add Menu to open link - pub fn link_with_icon( - mut self, - label: impl Into, - icon: impl Into, - href: impl Into, - ) -> Self { - let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: Some(icon.into()), - label: label.into(), - action: None, - handler: Rc::new(move |_window, cx| cx.open_url(&href)), - }); - self - } - - /// Add Menu Item with Icon - pub fn menu_with_icon( - mut self, - label: impl Into, - icon: impl Into, - action: Box, - ) -> Self { - self.add_menu_item(label, Some(icon.into()), action); - self - } - - /// Add Menu Item with check icon - pub fn menu_with_check( - mut self, - label: impl Into, - checked: bool, - action: Box, - ) -> Self { - if checked { - self.add_menu_item(label, Some(IconName::Check.into()), action); - } else { - self.add_menu_item(label, None, action); - } - - self - } - - /// Add Menu Item with custom element render. - pub fn menu_with_element(mut self, builder: F, action: Box) -> 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()), - handler: self.wrap_handler(action), - }); - self - } - - #[allow(clippy::type_complexity)] - fn wrap_handler(&self, action: Box) -> Rc { - let action_focus_handle = self.action_focus_handle.clone(); - - Rc::new(move |window, cx| { - window.activate_window(); - - // Focus back to the user expected focus handle - // Then the actions listened on that focus handle can be received - // - // For example: - // - // TabPanel - // |- PopupMenu - // |- PanelContent (actions are listened here) - // - // The `PopupMenu` and `PanelContent` are at the same level in the TabPanel - // If the actions are listened on the `PanelContent`, - // it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`. - if let Some(handle) = action_focus_handle.as_ref() { - window.focus(handle); - } - - window.dispatch_action(action.boxed_clone(), cx); - }) - } - - fn add_menu_item( - &mut self, - label: impl Into, - icon: Option, - action: Box, - ) -> &mut Self { - if icon.is_some() { - self.has_icon = true; - } - - self.menu_items.push(PopupMenuItem::Item { - icon, - label: label.into(), - action: Some(action.boxed_clone()), - handler: self.wrap_handler(action), - }); - self - } - - /// Add a title menu item - pub fn title(mut self, label: impl Into) -> Self { - if self.menu_items.is_empty() { - return self; - } - - if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() { - return self; - } - - self.menu_items.push(PopupMenuItem::Title(label.into())); - self - } - - /// Add a separator Menu Item - pub fn separator(mut self) -> Self { - if self.menu_items.is_empty() { - return self; - } - - if let Some(PopupMenuItem::Separator) = self.menu_items.last() { - return self; - } - - self.menu_items.push(PopupMenuItem::Separator); - self - } - - pub fn submenu( - self, - label: impl Into, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon(None, label, window, cx, f) - } - - /// Add a Submenu item with icon - pub fn submenu_with_icon( - mut self, - icon: Option, - label: impl Into, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - let submenu = PopupMenu::build(window, cx, f); - let parent_menu = cx.entity().downgrade(); - - submenu.update(cx, |view, _| { - view.parent_menu = Some(parent_menu); - }); - - self.menu_items.push(PopupMenuItem::Submenu { - icon, - label: label.into(), - menu: submenu, - }); - self - } - - pub(crate) fn active_submenu(&self) -> Option> { - if let Some(ix) = self.hovered_menu_ix { - if let Some(item) = self.menu_items.get(ix) { - return match item { - PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()), - _ => None, - }; - } - } - - None - } - - pub fn is_empty(&self) -> bool { - self.menu_items.is_empty() - } - - fn clickable_menu_items(&self) -> impl Iterator { - self.menu_items - .iter() - .enumerate() - .filter(|(_, item)| item.is_clickable()) - } - - fn on_click(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { - cx.stop_propagation(); - window.prevent_default(); - self.selected_index = Some(ix); - self.confirm(&Confirm, window, cx); - } - - 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, .. }) => { - handler(window, cx); - self.dismiss(&Dismiss, window, cx) - } - Some(PopupMenuItem::ElementItem { handler, .. }) => { - handler(window, cx); - self.dismiss(&Dismiss, window, cx) - } - _ => {} - } - } - } - - fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { - let count = self.clickable_menu_items().count(); - if count > 0 { - let last_ix = count.saturating_sub(1); - let ix = self - .selected_index - .map(|index| if index == last_ix { 0 } else { index + 1 }) - .unwrap_or(0); - - self.selected_index = Some(ix); - cx.notify(); - } - } - - fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context) { - let count = self.clickable_menu_items().count(); - if count > 0 { - let last_ix = count.saturating_sub(1); - - let ix = self - .selected_index - .map(|index| { - if index == last_ix { - 0 - } else { - index.saturating_sub(1) - } - }) - .unwrap_or(last_ix); - self.selected_index = Some(ix); - cx.notify(); - } - } - - // TODO: fix this - #[allow(clippy::only_used_in_recursion)] - fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context) { - if self.active_submenu().is_some() { - return; - } - - cx.emit(DismissEvent); - - // Dismiss parent menu, when this menu is dismissed - if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) { - parent_menu.update(cx, |view, cx| { - view.hovered_menu_ix = None; - view.dismiss(&Dismiss, window, cx); - }) - } - } - - fn render_keybinding( - action: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Option { - if let Some(action) = action { - if let Some(keybinding) = window.bindings_for_action(action.deref()).first() { - let el = div().text_color(cx.theme().text_muted).children( - keybinding - .keystrokes() - .iter() - .map(|key| key_shortcut(key.as_keystroke().clone())), - ); - - return Some(el); - } - } - - None - } - - fn render_icon( - has_icon: bool, - icon: Option, - _window: &Window, - _cx: &Context, - ) -> Option { - let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None }; - - if !has_icon { - return None; - } - - let icon = h_flex() - .w_3p5() - .h_3p5() - .items_center() - .justify_center() - .text_sm() - .map(|this| { - if let Some(icon) = icon { - this.child(icon.clone().small()) - } else { - this.children(icon_placeholder.clone()) - } - }); - - Some(icon) - } -} - -impl FluentBuilder for PopupMenu {} - -impl EventEmitter for PopupMenu {} - -impl Focusable for PopupMenu { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PopupMenu { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let view = cx.entity().clone(); - let has_icon = self.menu_items.iter().any(|item| item.has_icon()); - let items_count = self.menu_items.len(); - let max_width = self.max_width; - let bounds = self.bounds; - - let window_haft_height = window.window_bounds().get_bounds().size.height * 0.5; - let max_height = window_haft_height.min(px(450.)); - - v_flex() - .id("popup-menu") - .key_context("PopupMenu") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_prev)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::dismiss)) - .on_mouse_down_out(cx.listener(|this, _, window, cx| this.dismiss(&Dismiss, window, cx))) - .popover_style(cx) - .relative() - .p_1() - .child( - div() - .id("popup-menu-items") - .when(self.scrollable, |this| { - this.max_h(max_height) - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - }) - .child( - v_flex() - .gap_y_0p5() - .min_w(self.min_width) - .max_w(self.max_width) - .min_w(rems(8.)) - .child({ - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full() - }) - .children( - self.menu_items - .iter_mut() - .enumerate() - // Skip last separator - .filter(|(ix, item)| !(*ix == items_count - 1 && item.is_separator())) - .map(|(ix, item)| { - let this = ListItem::new(("menu-item", ix)) - .relative() - .items_center() - .py_0() - .px_2() - .rounded_md() - .text_sm() - .on_mouse_enter(cx.listener(move |this, _, _window, cx| { - this.hovered_menu_ix = Some(ix); - cx.notify(); - })); - - match item { - PopupMenuItem::Title(label) => { - this.child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(label.clone()) - ) - }, - PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child( - div() - .rounded_none() - .h(px(1.)) - .mx_neg_1() - .my_0p5() - .bg(cx.theme().border_disabled), - ), - PopupMenuItem::ElementItem { render, .. } => this - .on_click( - cx.listener(move |this, _, window, cx| { - this.on_click(ix, window, cx) - }), - ) - .child( - h_flex() - .min_h(ITEM_HEIGHT) - .items_center() - .gap_x_1() - .children(Self::render_icon(has_icon, None, window, cx)) - .child((render)(window, cx)), - ), - PopupMenuItem::Item { - icon, label, action, .. - } => { - let action = action.as_ref().map(|action| action.boxed_clone()); - let key = Self::render_keybinding(action, window, cx); - - this.on_click( - cx.listener(move |this, _, window, cx| { - this.on_click(ix, window, cx) - }), - ) - .child( - h_flex() - .h(ITEM_HEIGHT) - .items_center() - .gap_x_1p5() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) - .child( - h_flex() - .flex_1() - .gap_2() - .items_center() - .justify_between() - .child(label.clone()) - .children(key), - ), - ) - } - PopupMenuItem::Submenu { icon, label, menu } => this - .when(self.hovered_menu_ix == Some(ix), |this| this.selected(true)) - .child( - h_flex() - .items_start() - .child( - h_flex() - .size_full() - .items_center() - .gap_x_1p5() - .children(Self::render_icon( - has_icon, - icon.clone(), - window, - cx, - )) - .child( - h_flex() - .flex_1() - .gap_2() - .items_center() - .justify_between() - .child(label.clone()) - .child(IconName::CaretRight), - ), - ) - .when_some(self.hovered_menu_ix, |this, hovered_ix| { - let (anchor, left) = if window.bounds().size.width - - bounds.origin.x - < max_width - { - (Corner::TopRight, -px(15.)) - } else { - (Corner::TopLeft, bounds.size.width - px(10.)) - }; - - let top = if bounds.origin.y + bounds.size.height - > window.bounds().size.height - { - px(32.) - } else { - -px(10.) - }; - - if hovered_ix == ix { - this.child( - anchored() - .anchor(anchor) - .child( - div() - .occlude() - .top(top) - .left(left) - .child(menu.clone()), - ) - .snap_to_window_with_margin(Edges::all(px(8.))), - ) - } else { - this - } - }), - ), - } - }), - ), - ), - ) - .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_0p5() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), - ) - }) - } -} - -/// Return the Platform specific keybinding string by KeyStroke -pub fn key_shortcut(key: Keystroke) -> String { - if cfg!(target_os = "macos") { - return format!("{key}"); - } - - let mut parts = vec![]; - if key.modifiers.control { - parts.push("Ctrl"); - } - if key.modifiers.alt { - parts.push("Alt"); - } - if key.modifiers.platform { - parts.push("Win"); - } - if key.modifiers.shift { - parts.push("Shift"); - } - - // Capitalize the first letter - let key = if let Some(first_c) = key.key.chars().next() { - format!("{}{}", first_c.to_uppercase(), &key.key[1..]) - } else { - key.key.to_string() - }; - - parts.push(&key); - parts.join("+") -} diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 6cc9480..febd958 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -183,39 +183,43 @@ impl StyleSized for T { fn input_pl(self, size: Size) -> Self { match size { - Size::Large => self.pl_5(), + Size::XSmall => self.pl_1(), Size::Medium => self.pl_3(), + Size::Large => self.pl_5(), _ => self.pl_2(), } } fn input_pr(self, size: Size) -> Self { match size { - Size::Large => self.pr_5(), + Size::XSmall => self.pr_1(), Size::Medium => self.pr_3(), + Size::Large => self.pr_5(), _ => self.pr_2(), } } fn input_px(self, size: Size) -> Self { match size { - Size::Large => self.px_5(), + Size::XSmall => self.px_1(), Size::Medium => self.px_3(), + Size::Large => self.px_5(), _ => self.px_2(), } } fn input_py(self, size: Size) -> Self { match size { - Size::Large => self.py_5(), + Size::XSmall => self.py_0p5(), Size::Medium => self.py_2(), + Size::Large => self.py_5(), _ => self.py_1(), } } fn input_h(self, size: Size) -> Self { match size { - Size::XSmall => self.h_7(), + Size::XSmall => self.h_6(), Size::Small => self.h_8(), Size::Medium => self.h_9(), Size::Large => self.h_12(),