feat: revamp the chat panel ui #7
176
Cargo.lock
generated
176
Cargo.lock
generated
@@ -286,9 +286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.39"
|
version = "0.4.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f"
|
checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -298,9 +298,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-executor"
|
name = "async-executor"
|
||||||
version = "1.13.3"
|
version = "1.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
@@ -605,9 +605,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-rs"
|
name = "aws-lc-rs"
|
||||||
version = "1.15.4"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -812,9 +812,9 @@ checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
@@ -1009,10 +1009,9 @@ dependencies = [
|
|||||||
"chat",
|
"chat",
|
||||||
"common",
|
"common",
|
||||||
"dock",
|
"dock",
|
||||||
"emojis",
|
"flume",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui_tokio",
|
"gpui_tokio",
|
||||||
"indexset",
|
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"log",
|
"log",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
@@ -1073,9 +1072,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.58"
|
version = "4.5.59"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -1083,9 +1082,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.58"
|
version = "4.5.59"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -1194,7 +1193,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "collections"
|
name = "collections"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
@@ -1253,9 +1252,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.36"
|
version = "0.4.37"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a"
|
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"deflate64",
|
"deflate64",
|
||||||
@@ -1498,9 +1497,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cosmic-text"
|
name = "cosmic-text"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b"
|
checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
@@ -1639,7 +1638,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_refineable"
|
name = "derive_refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1848,15 +1847,6 @@ dependencies = [
|
|||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "emojis"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4"
|
|
||||||
dependencies = [
|
|
||||||
"phf",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -2194,7 +2184,7 @@ checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fontconfig-parser",
|
"fontconfig-parser",
|
||||||
"log",
|
"log",
|
||||||
"memmap2 0.9.9",
|
"memmap2 0.9.10",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"ttf-parser",
|
"ttf-parser",
|
||||||
@@ -2299,9 +2289,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2314,9 +2304,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -2324,15 +2314,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -2341,9 +2331,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
@@ -2375,9 +2365,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2386,21 +2376,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2410,7 +2400,6 @@ dependencies = [
|
|||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2599,7 +2588,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"as-raw-xcb-connection",
|
"as-raw-xcb-connection",
|
||||||
@@ -2699,7 +2688,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macros"
|
name = "gpui_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2710,7 +2699,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_tokio"
|
name = "gpui_tokio"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -2939,7 +2928,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client"
|
name = "http_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -2964,7 +2953,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client_tls"
|
name = "http_client_tls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
@@ -3202,7 +3191,7 @@ dependencies = [
|
|||||||
"image-webp",
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png 0.18.0",
|
"png 0.18.1",
|
||||||
"qoi",
|
"qoi",
|
||||||
"ravif",
|
"ravif",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3722,6 +3711,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3745,7 +3743,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "media"
|
name = "media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -3774,9 +3772,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.9"
|
version = "0.9.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
|
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -3910,9 +3908,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.16"
|
version = "0.2.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc"
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4004,7 +4002,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.44.1"
|
version = "0.44.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4029,7 +4027,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-connect"
|
name = "nostr-connect"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -4042,7 +4040,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-database"
|
name = "nostr-database"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btreecap",
|
"btreecap",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
@@ -4054,7 +4052,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip"
|
name = "nostr-gossip"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
]
|
]
|
||||||
@@ -4062,7 +4060,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip-memory"
|
name = "nostr-gossip-memory"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -4074,7 +4072,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-lmdb"
|
name = "nostr-lmdb"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"flume",
|
"flume",
|
||||||
@@ -4088,7 +4086,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-sdk"
|
name = "nostr-sdk"
|
||||||
version = "0.44.1"
|
version = "0.44.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#8410867ef02376a5bd7c7ec3f2af46a1c276a2f4"
|
source = "git+https://github.com/rust-nostr/nostr#bd92fd901e8b64856ad4f8373fbb87376314161c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"async-wsocket",
|
"async-wsocket",
|
||||||
@@ -4562,7 +4560,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "perf"
|
name = "perf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4711,9 +4709,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.0"
|
version = "0.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
@@ -5238,7 +5236,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "refineable"
|
name = "refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_refineable",
|
"derive_refineable",
|
||||||
]
|
]
|
||||||
@@ -5343,7 +5341,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest_client"
|
name = "reqwest_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5398,7 +5396,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rope"
|
name = "rope"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"log",
|
"log",
|
||||||
@@ -5660,7 +5658,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "scheduler"
|
name = "scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
@@ -6241,7 +6239,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sum_tree"
|
name = "sum_tree"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"log",
|
"log",
|
||||||
@@ -6357,9 +6355,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.115"
|
version = "2.0.116"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
|
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6837,9 +6835,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
|
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
@@ -6951,10 +6949,14 @@ version = "0.3.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
@@ -7065,9 +7067,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.23"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-linebreak"
|
name = "unicode-linebreak"
|
||||||
@@ -7197,7 +7199,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util"
|
name = "util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-fs",
|
"async-fs",
|
||||||
@@ -7235,7 +7237,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util_macros"
|
name = "util_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"perf",
|
"perf",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -8704,7 +8706,7 @@ checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"as-raw-xcb-connection",
|
"as-raw-xcb-connection",
|
||||||
"libc",
|
"libc",
|
||||||
"memmap2 0.9.9",
|
"memmap2 0.9.10",
|
||||||
"xkeysym",
|
"xkeysym",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -9043,7 +9045,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zlog"
|
name = "zlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -9060,7 +9062,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing"
|
name = "ztracing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -9071,7 +9073,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing_macro"
|
name = "ztracing_macro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#b90a370c864a76dc53bc19b650a2a07fbb36b043"
|
source = "git+https://github.com/zed-industries/zed#f07cec59def58dd8199bdced27b1aafbeb5755d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
|
|||||||
3
assets/icons/paper-plane-fill.svg
Normal file
3
assets/icons/paper-plane-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
@@ -231,13 +231,13 @@ impl AutoUpdater {
|
|||||||
|
|
||||||
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let _client = nostr.read(cx).client();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
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 app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||||
|
|
||||||
let filter = Filter::new()
|
let _filter = Filter::new()
|
||||||
.kind(Kind::ReleaseArtifactSet)
|
.kind(Kind::ReleaseArtifactSet)
|
||||||
.author(app_pubkey)
|
.author(app_pubkey)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -253,7 +253,7 @@ impl AutoUpdater {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
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 app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -274,7 +274,7 @@ impl AutoUpdater {
|
|||||||
// Get all file metadata event ids
|
// Get all file metadata event ids
|
||||||
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
||||||
|
|
||||||
let filter = Filter::new()
|
let _filter = Filter::new()
|
||||||
.kind(Kind::FileMetadata)
|
.kind(Kind::FileMetadata)
|
||||||
.author(app_pubkey)
|
.author(app_pubkey)
|
||||||
.ids(ids.clone());
|
.ids(ids.clone());
|
||||||
|
|||||||
@@ -7,16 +7,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use common::EventUtils;
|
use common::EventUtils;
|
||||||
use device::DeviceRegistry;
|
|
||||||
use flume::Sender;
|
|
||||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
use fuzzy_matcher::FuzzyMatcher;
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
use gpui::{
|
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 nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP};
|
use state::{NostrRegistry, DEVICE_GIFTWRAP, USER_GIFTWRAP};
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
mod room;
|
mod room;
|
||||||
@@ -24,8 +22,8 @@ mod room;
|
|||||||
pub use message::*;
|
pub use message::*;
|
||||||
pub use room::*;
|
pub use room::*;
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||||
@@ -45,11 +43,9 @@ pub enum ChatEvent {
|
|||||||
|
|
||||||
/// Channel signal.
|
/// Channel signal.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
enum NostrEvent {
|
enum Signal {
|
||||||
/// Message received from relay pool
|
/// Message received from relay pool
|
||||||
Message(NewMessage),
|
Message(NewMessage),
|
||||||
/// Unwrapping status
|
|
||||||
Unwrapping(bool),
|
|
||||||
/// Eose received from relay pool
|
/// Eose received from relay pool
|
||||||
Eose,
|
Eose,
|
||||||
}
|
}
|
||||||
@@ -60,23 +56,11 @@ pub struct ChatRegistry {
|
|||||||
/// Collection of all chat rooms
|
/// Collection of all chat rooms
|
||||||
rooms: Vec<Entity<Room>>,
|
rooms: Vec<Entity<Room>>,
|
||||||
|
|
||||||
/// Loading status of the registry
|
|
||||||
loading: bool,
|
|
||||||
|
|
||||||
/// Channel's sender for communication between nostr and gpui
|
|
||||||
sender: Sender<NostrEvent>,
|
|
||||||
|
|
||||||
/// Tracking the status of unwrapping gift wrap events.
|
/// Tracking the status of unwrapping gift wrap events.
|
||||||
tracking_flag: Arc<AtomicBool>,
|
tracking_flag: Arc<AtomicBool>,
|
||||||
|
|
||||||
/// Handle tracking asynchronous task
|
/// Async tasks
|
||||||
tracking: Option<Task<Result<(), Error>>>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
|
||||||
|
|
||||||
/// Handle notifications asynchronous task
|
|
||||||
notifications: Option<Task<()>>,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
|
||||||
tasks: Vec<Task<()>>,
|
|
||||||
|
|
||||||
/// Subscriptions
|
/// Subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
@@ -96,82 +80,38 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new chat registry instance
|
/// Create a new chat registry instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
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::<NostrEvent>(2048);
|
|
||||||
|
|
||||||
let mut tasks = vec![];
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the identity
|
// Observe the nip65 state and load chat rooms on every state change
|
||||||
cx.observe(&nip17_state, |this, state, cx| {
|
cx.observe(&nip65, |this, state, cx| {
|
||||||
if state.read(cx) == &RelayState::Configured {
|
if state.read(cx).idle() {
|
||||||
// Handle nostr notifications
|
this.reset(cx);
|
||||||
this.handle_notifications(cx);
|
|
||||||
// Track unwrapping progress
|
|
||||||
this.tracking(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);
|
this.get_rooms(cx);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
// Observe the device signer state
|
this.handle_notifications(cx);
|
||||||
cx.observe(&device_signer, |this, state, cx| {
|
this.tracking(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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
rooms: vec![],
|
rooms: vec![],
|
||||||
loading: false,
|
tracking_flag: Arc::new(AtomicBool::new(false)),
|
||||||
sender: tx.clone(),
|
tasks: smallvec![],
|
||||||
tracking_flag,
|
|
||||||
tracking: None,
|
|
||||||
notifications: None,
|
|
||||||
tasks,
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,18 +120,18 @@ impl ChatRegistry {
|
|||||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
let device = DeviceRegistry::global(cx);
|
|
||||||
let device_signer = device.read(cx).signer(cx);
|
|
||||||
|
|
||||||
let status = self.tracking_flag.clone();
|
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 initialized_at = Timestamp::now();
|
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
||||||
let sub_id1 = SubscriptionId::new(DEVICE_GIFTWRAP);
|
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
||||||
let sub_id2 = SubscriptionId::new(USER_GIFTWRAP);
|
|
||||||
|
|
||||||
|
// Channel for communication between nostr and gpui
|
||||||
|
let (tx, rx) = flume::bounded::<Signal>(1024);
|
||||||
|
|
||||||
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
let device_signer = signer.get_encryption_signer().await;
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut processed_events = HashSet::new();
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
@@ -213,23 +153,16 @@ impl ChatRegistry {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!("Received gift wrap event: {:?}", event);
|
||||||
|
|
||||||
// Extract the rumor from the gift wrap event
|
// Extract the rumor from the gift wrap event
|
||||||
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
match Self::extract_rumor(&client, &device_signer, event.as_ref()).await {
|
||||||
Ok(rumor) => match rumor.created_at >= initialized_at {
|
Ok(rumor) => match rumor.created_at >= initialized_at {
|
||||||
true => {
|
true => {
|
||||||
// Check if the event is sent by coop
|
let new_message = NewMessage::new(event.id, rumor);
|
||||||
let sent_by_coop = {
|
let signal = Signal::Message(new_message);
|
||||||
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);
|
|
||||||
|
|
||||||
tx.send_async(signal).await.ok();
|
tx.send_async(signal).await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
false => {
|
false => {
|
||||||
status.store(true, Ordering::Release);
|
status.store(true, Ordering::Release);
|
||||||
@@ -242,29 +175,46 @@ impl ChatRegistry {
|
|||||||
}
|
}
|
||||||
RelayMessage::EndOfStoredEvents(id) => {
|
RelayMessage::EndOfStoredEvents(id) => {
|
||||||
if id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2 {
|
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.
|
/// Tracking the status of unwrapping gift wrap events.
|
||||||
fn tracking(&mut self, cx: &mut Context<Self>) {
|
fn tracking(&mut self, cx: &mut Context<Self>) {
|
||||||
let status = self.tracking_flag.clone();
|
let status = self.tracking_flag.clone();
|
||||||
let tx = self.sender.clone();
|
|
||||||
|
|
||||||
self.tracking = Some(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
let loop_duration = Duration::from_secs(12);
|
let loop_duration = Duration::from_secs(10);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if status.load(Ordering::Acquire) {
|
if status.load(Ordering::Acquire) {
|
||||||
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
_ = 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;
|
smol::Timer::after(loop_duration).await;
|
||||||
}
|
}
|
||||||
@@ -273,13 +223,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Get the loading status of the chat registry
|
/// Get the loading status of the chat registry
|
||||||
pub fn loading(&self) -> bool {
|
pub fn loading(&self) -> bool {
|
||||||
self.loading
|
self.tracking_flag.load(Ordering::Acquire)
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the loading status of the chat registry
|
|
||||||
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
|
||||||
self.loading = loading;
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a weak reference to a room by its ID.
|
/// Get a weak reference to a room by its ID.
|
||||||
@@ -315,19 +259,19 @@ impl ChatRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Some(signer) = client.signer() {
|
let signer = client.signer()?;
|
||||||
if let Ok(public_key) = signer.get_public_key().await {
|
let public_key = signer.get_public_key().await.ok()?;
|
||||||
this.update(cx, |this, cx| {
|
let room: Room = room.into().organize(&public_key);
|
||||||
this.rooms
|
|
||||||
.insert(0, cx.new(|_| room.into().organize(&public_key)));
|
this.update(cx, |this, cx| {
|
||||||
cx.emit(ChatEvent::Ping);
|
this.rooms.insert(0, cx.new(|_| room));
|
||||||
cx.notify();
|
cx.emit(ChatEvent::Ping);
|
||||||
})
|
cx.notify();
|
||||||
.ok();
|
})
|
||||||
}
|
.ok()
|
||||||
}
|
})
|
||||||
}));
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit an open room event.
|
/// Emit an open room event.
|
||||||
@@ -420,20 +364,16 @@ impl ChatRegistry {
|
|||||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
let task = self.get_rooms_from_database(cx);
|
let task = self.get_rooms_from_database(cx);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
match task.await {
|
let rooms = task.await.ok()?;
|
||||||
Ok(rooms) => {
|
|
||||||
this.update(cx, move |this, cx| {
|
this.update(cx, move |this, cx| {
|
||||||
this.extend_rooms(rooms, cx);
|
this.extend_rooms(rooms, cx);
|
||||||
this.sort(cx);
|
this.sort(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok()
|
||||||
}
|
})
|
||||||
Err(e) => {
|
.detach();
|
||||||
log::error!("Failed to load rooms: {e}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a task to load rooms from the database
|
/// Create a task to load rooms from the database
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use nostr_sdk::prelude::*;
|
|||||||
/// New message.
|
/// New message.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct NewMessage {
|
pub struct NewMessage {
|
||||||
pub gift_wrap: EventId,
|
|
||||||
pub room: u64,
|
pub room: u64,
|
||||||
|
pub gift_wrap: EventId,
|
||||||
pub rumor: UnsignedEvent,
|
pub rumor: UnsignedEvent,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ impl NewMessage {
|
|||||||
let room = rumor.uniq_id();
|
let room = rumor.uniq_id();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
gift_wrap,
|
|
||||||
room,
|
room,
|
||||||
|
gift_wrap,
|
||||||
rumor,
|
rumor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -9,73 +8,59 @@ use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
|||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use state::{tracker, NostrRegistry};
|
use settings::{RoomConfig, SignerKind};
|
||||||
|
use state::{NostrRegistry, TIMEOUT};
|
||||||
|
|
||||||
use crate::{ChatRegistry, NewMessage};
|
use crate::{ChatRegistry, NewMessage};
|
||||||
|
|
||||||
const SEND_RETRY: usize = 10;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SendReport {
|
pub struct SendReport {
|
||||||
pub receiver: PublicKey,
|
pub receiver: PublicKey,
|
||||||
pub status: Option<Output<EventId>>,
|
pub gift_wrap_id: Option<EventId>,
|
||||||
pub error: Option<SharedString>,
|
pub error: Option<SharedString>,
|
||||||
pub on_hold: Option<Event>,
|
pub output: Option<Output<EventId>>,
|
||||||
pub encryption: bool,
|
|
||||||
pub relays_not_found: bool,
|
|
||||||
pub device_not_found: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendReport {
|
impl SendReport {
|
||||||
pub fn new(receiver: PublicKey) -> Self {
|
pub fn new(receiver: PublicKey) -> Self {
|
||||||
Self {
|
Self {
|
||||||
receiver,
|
receiver,
|
||||||
status: None,
|
gift_wrap_id: None,
|
||||||
error: None,
|
error: None,
|
||||||
on_hold: None,
|
output: None,
|
||||||
encryption: false,
|
|
||||||
relays_not_found: false,
|
|
||||||
device_not_found: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
/// Set the gift wrap ID.
|
||||||
self.status = Some(output);
|
pub fn gift_wrap_id(mut self, gift_wrap_id: EventId) -> Self {
|
||||||
|
self.gift_wrap_id = Some(gift_wrap_id);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
/// Set the output.
|
||||||
|
pub fn output(mut self, output: Output<EventId>) -> Self {
|
||||||
|
self.output = Some(output);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the error message.
|
||||||
|
pub fn error<T>(mut self, error: T) -> Self
|
||||||
|
where
|
||||||
|
T: Into<SharedString>,
|
||||||
|
{
|
||||||
self.error = Some(error.into());
|
self.error = Some(error.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_hold(mut self, event: Event) -> Self {
|
/// Returns true if the send is pending.
|
||||||
self.on_hold = Some(event);
|
pub fn pending(&self) -> bool {
|
||||||
self
|
self.output.is_none() && self.error.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encryption(mut self) -> Self {
|
/// Returns true if the send was successful.
|
||||||
self.encryption = true;
|
pub fn success(&self) -> bool {
|
||||||
self
|
if let Some(output) = self.output.as_ref() {
|
||||||
}
|
!output.failed.is_empty()
|
||||||
|
|
||||||
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()
|
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -115,6 +100,9 @@ pub struct Room {
|
|||||||
|
|
||||||
/// Kind
|
/// Kind
|
||||||
pub kind: RoomKind,
|
pub kind: RoomKind,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: RoomConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ord for Room {
|
impl Ord for Room {
|
||||||
@@ -161,6 +149,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
subject,
|
subject,
|
||||||
members,
|
members,
|
||||||
kind: RoomKind::default(),
|
kind: RoomKind::default(),
|
||||||
|
config: RoomConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,34 +309,43 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get gossip relays for each member
|
/// Get gossip relays for each member
|
||||||
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
pub fn early_connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let members = self.members();
|
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 {
|
cx.background_spawn(async move {
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
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() {
|
for member in members.into_iter() {
|
||||||
if member == public_key {
|
if member == public_key {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Construct a filter for gossip relays
|
// Construct a filter for messaging relays
|
||||||
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
|
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
|
// Subscribe to get member's gossip relays
|
||||||
client
|
client
|
||||||
.subscribe(filter)
|
.subscribe(vec![inbox, announcement])
|
||||||
.close_on(opts)
|
.with_id(subscription_id.clone())
|
||||||
.with_id(id.clone())
|
.close_on(
|
||||||
|
SubscribeAutoCloseOptions::default()
|
||||||
|
.timeout(Some(Duration::from_secs(TIMEOUT)))
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +377,194 @@ impl Room {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new unsigned message event
|
// Construct a rumor event for direct message
|
||||||
|
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
I: IntoIterator<Item = EventId>,
|
||||||
|
{
|
||||||
|
let kind = Kind::PrivateDirectMessage;
|
||||||
|
let content: String = content.into();
|
||||||
|
let replies: Vec<EventId> = 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<Person> = 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<Task<Vec<SendReport>>> {
|
||||||
|
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<Person> = 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(
|
pub fn create_message(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -444,7 +629,7 @@ impl Room {
|
|||||||
// WARNING: never sign and send this event to relays
|
// WARNING: never sign and send this event to relays
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(Keys::generate().public_key());
|
.build(public_key);
|
||||||
|
|
||||||
// Ensure the event ID has been generated
|
// Ensure the event ID has been generated
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
@@ -594,4 +779,5 @@ impl Room {
|
|||||||
Ok(resend_reports)
|
Ok(resend_reports)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ anyhow.workspace = true
|
|||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
indexset = "0.12.3"
|
|
||||||
emojis = "0.6.4"
|
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ use gpui::Action;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = chat, no_json)]
|
#[action(namespace = chat, no_json)]
|
||||||
pub struct SeenOn(pub EventId);
|
pub struct SeenOn(pub EventId);
|
||||||
|
|||||||
@@ -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<Vec<SharedString>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn get_emojis() -> &'static Vec<SharedString> {
|
|
||||||
EMOJIS.get_or_init(|| {
|
|
||||||
let mut emojis: Vec<SharedString> = vec![];
|
|
||||||
|
|
||||||
emojis.extend(
|
|
||||||
emojis::Group::SmileysAndEmotion
|
|
||||||
.emojis()
|
|
||||||
.map(|e| SharedString::from(e.as_str()))
|
|
||||||
.collect::<Vec<SharedString>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
emojis
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct EmojiPicker {
|
|
||||||
target: Option<WeakEntity<InputState>>,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
anchor: Option<Corner>,
|
|
||||||
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<InputState>) -> Self {
|
|
||||||
self.target = Some(target);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn icon(mut self, icon: impl Into<Icon>) -> 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<Size>) -> 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.))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,44 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
use anyhow::Error;
|
use anyhow::{Context as AnyhowContext, Error};
|
||||||
use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport};
|
use chat::{Message, RenderedMessage, Room, RoomEvent, SendReport};
|
||||||
use common::{nip96_upload, RenderedTimestamp};
|
use common::{nip96_upload, RenderedTimestamp};
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
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,
|
ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||||
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
|
IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement,
|
||||||
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
|
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
|
||||||
Styled, StyledImage, Subscription, Task, WeakEntity, Window,
|
Subscription, Task, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use indexset::{BTreeMap, BTreeSet};
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::{Person, PersonRegistry};
|
use person::{Person, PersonRegistry};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use smol::fs;
|
use smol::fs;
|
||||||
|
use smol::lock::RwLock;
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::context_menu::ContextMenuExt;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::menu::{ContextMenuExt, DropdownMenu};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::popup_menu::PopupMenuExt;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{
|
use ui::{
|
||||||
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
||||||
WindowExtension,
|
WindowExtension,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::emoji::EmojiPicker;
|
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod emoji;
|
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
@@ -49,7 +49,6 @@ pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity
|
|||||||
pub struct ChatPanel {
|
pub struct ChatPanel {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
|
||||||
|
|
||||||
/// Chat Room
|
/// Chat Room
|
||||||
room: WeakEntity<Room>,
|
room: WeakEntity<Room>,
|
||||||
@@ -63,12 +62,15 @@ pub struct ChatPanel {
|
|||||||
/// Mapping message ids to their rendered texts
|
/// Mapping message ids to their rendered texts
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
|
|
||||||
/// Mapping message ids to their reports
|
/// Mapping message (rumor event) ids to their reports
|
||||||
reports_by_id: BTreeMap<EventId, Vec<SendReport>>,
|
reports_by_id: Entity<BTreeMap<EventId, Vec<SendReport>>>,
|
||||||
|
|
||||||
/// Input state
|
/// Input state
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Sent message ids
|
||||||
|
sent_ids: Arc<RwLock<Vec<EventId>>>,
|
||||||
|
|
||||||
/// Replies to
|
/// Replies to
|
||||||
replies_to: Entity<HashSet<EventId>>,
|
replies_to: Entity<HashSet<EventId>>,
|
||||||
|
|
||||||
@@ -79,97 +81,63 @@ pub struct ChatPanel {
|
|||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
/// Async operations
|
/// Async operations
|
||||||
tasks: SmallVec<[Task<()>; 2]>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatPanel {
|
impl ChatPanel {
|
||||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> 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| {
|
let input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder("Message...")
|
.placeholder(format!("Message {}", name))
|
||||||
.auto_grow(1, 20)
|
.auto_grow(1, 20)
|
||||||
.prevent_new_line_on_enter()
|
.prevent_new_line_on_enter()
|
||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
let attachments = cx.new(|_| vec![]);
|
// Define subscriptions
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let subscriptions =
|
||||||
|
smallvec![
|
||||||
let messages = BTreeSet::from([Message::system()]);
|
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
||||||
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| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
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 {
|
Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
room,
|
room,
|
||||||
@@ -178,38 +146,113 @@ impl ChatPanel {
|
|||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id: BTreeMap::new(),
|
reports_by_id,
|
||||||
|
sent_ids: Arc::new(RwLock::new(Vec::new())),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
subscriptions,
|
||||||
focus_handle: cx.focus_handle(),
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
|
||||||
tasks,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle nostr notifications
|
||||||
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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
|
/// Load all messages belonging to this room
|
||||||
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) {
|
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
return;
|
||||||
let result = get_messages.await;
|
};
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
match result {
|
let events = get_messages.await?;
|
||||||
Ok(events) => {
|
|
||||||
this.insert_messages(&events, cx);
|
// Update message list
|
||||||
}
|
this.update(cx, |this, cx| {
|
||||||
Err(e) => {
|
this.insert_messages(&events, cx);
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
})?;
|
||||||
}
|
|
||||||
};
|
Ok(())
|
||||||
})
|
}));
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user input content and merged all attachments
|
/// Get user input content and merged all attachments if available
|
||||||
fn input_content(&self, cx: &Context<Self>) -> String {
|
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
||||||
// Get input's value
|
// Get input's value
|
||||||
let mut content = self.input.read(cx).value().trim().to_string();
|
let mut content = self.input.read(cx).value().trim().to_string();
|
||||||
|
|
||||||
@@ -233,10 +276,9 @@ impl ChatPanel {
|
|||||||
content
|
content
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to all members of the chat
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Get the message which includes all attachments
|
// 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
|
// Return if message is empty
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
@@ -244,80 +286,97 @@ impl ChatPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get replies_to if it's present
|
self.send_message(&content, window, cx);
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
}
|
||||||
|
|
||||||
// Get a task to create temporary message for optimistic update
|
/// Send a message to all members of the chat
|
||||||
let Ok(get_rumor) = self
|
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
.room
|
if value.trim().is_empty() {
|
||||||
.read_with(cx, |this, cx| this.create_message(&content, replies, cx))
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
else {
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
// Optimistically update message list
|
// Get room entity
|
||||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
let room = self.room.clone();
|
||||||
let mut rumor = get_rumor.await?;
|
|
||||||
let rumor_id = rumor.id();
|
// Get content and replies
|
||||||
|
let replies: Vec<EventId> = 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.update_in(cx, |this, window, cx| {
|
||||||
this.remove_all_replies(cx);
|
match room.read(cx).rumor(content, replies, cx) {
|
||||||
this.remove_all_attachments(cx);
|
Some(rumor) => {
|
||||||
|
this.insert_message(&rumor, true, cx);
|
||||||
// Reset the input to its default state
|
this.send_and_wait(rumor, window, cx);
|
||||||
this.input.update(cx, |this, cx| {
|
this.clear(window, cx);
|
||||||
this.set_loading(false, cx);
|
}
|
||||||
this.set_disabled(false, cx);
|
None => {
|
||||||
this.set_value("", window, cx);
|
window.push_notification("Failed to create message", 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();
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
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<Self>) {
|
||||||
|
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>) {
|
||||||
|
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<SendReport>, cx: &mut Context<Self>) {
|
||||||
|
self.reports_by_id.update(cx, |this, cx| {
|
||||||
|
this.insert(id, reports);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a message into the chat panel
|
/// Insert a message into the chat panel
|
||||||
@@ -350,23 +409,33 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message failed to send by its ID
|
/// Check if a message is pending
|
||||||
fn is_sent_failed(&self, id: &EventId) -> bool {
|
fn sent_pending(&self, id: &EventId, cx: &App) -> bool {
|
||||||
self.reports_by_id
|
self.reports_by_id
|
||||||
|
.read(cx)
|
||||||
.get(id)
|
.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
|
/// Check if a message was sent successfully by its ID
|
||||||
fn is_sent_success(&self, id: &EventId) -> Option<bool> {
|
fn sent_success(&self, id: &EventId, cx: &App) -> bool {
|
||||||
self.reports_by_id
|
self.reports_by_id
|
||||||
|
.read(cx)
|
||||||
.get(id)
|
.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
|
/// Check if a message failed to send by its ID
|
||||||
fn sent_reports(&self, id: &EventId) -> Option<&Vec<SendReport>> {
|
fn sent_failed(&self, id: &EventId, cx: &App) -> Option<bool> {
|
||||||
self.reports_by_id.get(id)
|
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<Vec<SendReport>> {
|
||||||
|
self.reports_by_id.read(cx).get(id).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a message by its ID
|
/// Get a message by its ID
|
||||||
@@ -415,13 +484,6 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_all_replies(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.replies_to.update(cx, |this, cx| {
|
|
||||||
this.clear();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
@@ -436,9 +498,9 @@ impl ChatPanel {
|
|||||||
prompt: None,
|
prompt: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
let mut paths = path.await.ok()?.ok()??;
|
let mut paths = path.await??.context("Not found")?;
|
||||||
let path = paths.pop()?;
|
let path = paths.pop().context("No path")?;
|
||||||
|
|
||||||
let upload = Tokio::spawn(cx, async move {
|
let upload = Tokio::spawn(cx, async move {
|
||||||
let file = fs::read(path).await.ok()?;
|
let file = fs::read(path).await.ok()?;
|
||||||
@@ -467,9 +529,8 @@ impl ChatPanel {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(())
|
Ok(())
|
||||||
})
|
}));
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
||||||
@@ -493,28 +554,21 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_all_attachments(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.attachments.update(cx, |this, cx| {
|
|
||||||
this.clear();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Person {
|
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
fn render_announcement(&self, ix: usize, cx: &Context<Self>) -> AnyElement {
|
||||||
|
const MSG: &str =
|
||||||
|
"This conversation is private. Only members can see each other's messages.";
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.group("")
|
.h_40()
|
||||||
.h_32()
|
|
||||||
.w_full()
|
.w_full()
|
||||||
.relative()
|
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.px_3()
|
.p_3()
|
||||||
.py_2()
|
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.text_center()
|
.text_center()
|
||||||
@@ -524,12 +578,10 @@ impl ChatPanel {
|
|||||||
.child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
.path("brand/coop.svg")
|
.path("brand/coop.svg")
|
||||||
.size_10()
|
.size_12()
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
.text_color(cx.theme().ghost_element_active),
|
||||||
)
|
)
|
||||||
.child(SharedString::from(
|
.child(SharedString::from(MSG))
|
||||||
"This conversation is private. Only members can see each other's messages.",
|
|
||||||
))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +619,7 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if let Some(message) = self.messages.get_index(ix) {
|
if let Some(message) = self.messages.iter().nth(ix) {
|
||||||
match message {
|
match message {
|
||||||
Message::User(rendered) => {
|
Message::User(rendered) => {
|
||||||
let text = self
|
let text = self
|
||||||
@@ -592,7 +644,7 @@ impl ChatPanel {
|
|||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
message: &RenderedMessage,
|
message: &RenderedMessage,
|
||||||
text: AnyElement,
|
rendered_text: AnyElement,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let id = message.id;
|
let id = message.id;
|
||||||
@@ -603,10 +655,13 @@ impl ChatPanel {
|
|||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
|
|
||||||
// Check if message is sent failed
|
// 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
|
// 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
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -654,18 +709,21 @@ impl ChatPanel {
|
|||||||
.child(author.name()),
|
.child(author.name()),
|
||||||
)
|
)
|
||||||
.child(message.created_at.to_human_time())
|
.child(message.created_at.to_human_time())
|
||||||
.when_some(is_sent_success, |this, status| {
|
.when(sent_pending, |this| {
|
||||||
this.when(status, |this| {
|
this.child(deferred(Indicator::new().small()))
|
||||||
this.child(self.render_message_sent(&id, cx))
|
})
|
||||||
})
|
.when(sent_success, |this| {
|
||||||
|
this.child(deferred(self.render_sent_indicator(&id, cx)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
this.children(self.render_message_replies(replies, cx))
|
||||||
})
|
})
|
||||||
.child(text)
|
.child(rendered_text)
|
||||||
.when(is_sent_failed, |this| {
|
.when_some(sent_failed, |this, failed| {
|
||||||
this.child(self.render_message_reports(&id, cx))
|
this.when(failed, |this| {
|
||||||
|
this.child(deferred(self.render_message_reports(&id, cx)))
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -730,11 +788,11 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_message_sent(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
|
fn render_sent_indicator(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(id.to_hex()))
|
.id(SharedString::from(id.to_hex()))
|
||||||
.child(SharedString::from("• Sent"))
|
.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| {
|
this.on_click(move |_e, window, cx| {
|
||||||
let reports = reports.clone();
|
let reports = reports.clone();
|
||||||
|
|
||||||
@@ -766,7 +824,7 @@ impl ChatPanel {
|
|||||||
.child(SharedString::from(
|
.child(SharedString::from(
|
||||||
"Failed to send message. Click to see details.",
|
"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| {
|
this.on_click(move |_e, window, cx| {
|
||||||
let reports = reports.clone();
|
let reports = reports.clone();
|
||||||
|
|
||||||
@@ -809,48 +867,6 @@ impl ChatPanel {
|
|||||||
.child(name.clone()),
|
.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| {
|
.when_some(report.error.clone(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -866,7 +882,7 @@ impl ChatPanel {
|
|||||||
.child(div().flex_1().w_full().text_center().child(error)),
|
.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(
|
this.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -993,9 +1009,9 @@ impl ChatPanel {
|
|||||||
.icon(IconName::Ellipsis)
|
.icon(IconName::Ellipsis)
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.popup_menu({
|
.dropdown_menu({
|
||||||
let id = id.to_owned();
|
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())
|
.group_hover("", |this| this.visible())
|
||||||
@@ -1116,6 +1132,25 @@ impl ChatPanel {
|
|||||||
|
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
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 {
|
impl Panel for ChatPanel {
|
||||||
@@ -1150,61 +1185,86 @@ impl Focusable for ChatPanel {
|
|||||||
impl Render for ChatPanel {
|
impl Render for ChatPanel {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(self.image_cache.clone())
|
.on_action(cx.listener(Self::on_command))
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
list(
|
div()
|
||||||
self.list_state.clone(),
|
.flex_1()
|
||||||
cx.processor(|this, ix, window, cx| {
|
.size_full()
|
||||||
// Get and render message by index
|
.child(
|
||||||
this.render_message(ix, window, cx)
|
list(
|
||||||
}),
|
self.list_state.clone(),
|
||||||
)
|
cx.processor(move |this, ix, window, cx| {
|
||||||
.flex_1(),
|
this.render_message(ix, window, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.size_full(),
|
||||||
|
)
|
||||||
|
.child(Scrollbar::vertical(&self.list_state)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.p_2()
|
||||||
.w_full()
|
.w_full()
|
||||||
.relative()
|
.gap_1p5()
|
||||||
.px_3()
|
.children(self.render_attachment_list(window, cx))
|
||||||
.py_2()
|
.children(self.render_reply_list(window, cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
h_flex()
|
||||||
.gap_1p5()
|
.items_end()
|
||||||
.children(self.render_attachment_list(window, cx))
|
|
||||||
.children(self.render_reply_list(window, cx))
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
Button::new("upload")
|
||||||
.w_full()
|
.icon(IconName::Plus)
|
||||||
.flex()
|
.tooltip("Upload media")
|
||||||
.items_end()
|
.loading(self.uploading)
|
||||||
.gap_2p5()
|
.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(
|
.child(
|
||||||
h_flex()
|
Button::new("emoji")
|
||||||
.gap_1()
|
.icon(IconName::Emoji)
|
||||||
.text_color(cx.theme().text_muted)
|
.ghost()
|
||||||
.child(
|
.large()
|
||||||
Button::new("upload")
|
.dropdown_menu_with_anchor(
|
||||||
.icon(IconName::Upload)
|
gpui::Corner::BottomLeft,
|
||||||
.loading(self.uploading)
|
move |this, _window, _cx| {
|
||||||
.disabled(self.uploading)
|
this.horizontal()
|
||||||
.ghost()
|
.menu("👍", Box::new(Command::Insert("👍")))
|
||||||
.large()
|
.menu("👎", Box::new(Command::Insert("👎")))
|
||||||
.on_click(cx.listener(
|
.menu("😄", Box::new(Command::Insert("😄")))
|
||||||
move |this, _, window, cx| {
|
.menu("🎉", Box::new(Command::Insert("🎉")))
|
||||||
this.upload(window, cx);
|
.menu("😕", Box::new(Command::Insert("😕")))
|
||||||
},
|
.menu("❤️", Box::new(Command::Insert("❤️")))
|
||||||
)),
|
.menu("🚀", Box::new(Command::Insert("🚀")))
|
||||||
)
|
.menu("👀", Box::new(Command::Insert("👀")))
|
||||||
.child(
|
},
|
||||||
EmojiPicker::new()
|
|
||||||
.target(self.input.downgrade())
|
|
||||||
.icon(IconName::Emoji)
|
|
||||||
.large(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.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);
|
||||||
|
})),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ futures.workspace = true
|
|||||||
oneshot.workspace = true
|
oneshot.workspace = true
|
||||||
|
|
||||||
indexset = "0.12.3"
|
indexset = "0.12.3"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
|
||||||
|
|||||||
@@ -78,26 +78,26 @@ fn main() {
|
|||||||
// Initialize theme registry
|
// Initialize theme registry
|
||||||
theme::init(cx);
|
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
|
// Initialize settings
|
||||||
settings::init(cx);
|
settings::init(cx);
|
||||||
|
|
||||||
|
// Initialize the nostr client
|
||||||
|
state::init(window, cx);
|
||||||
|
|
||||||
// Initialize relay auth registry
|
// Initialize relay auth registry
|
||||||
relay_auth::init(window, cx);
|
relay_auth::init(window, cx);
|
||||||
|
|
||||||
// Initialize app registry
|
|
||||||
chat::init(cx);
|
|
||||||
|
|
||||||
// Initialize person registry
|
// Initialize person registry
|
||||||
person::init(cx);
|
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
|
// Initialize auto update
|
||||||
auto_update::init(cx);
|
auto_update::init(cx);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use chat::RoomKind;
|
use chat::RoomKind;
|
||||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
|
||||||
use dock::ClosePanel;
|
use dock::ClosePanel;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -12,7 +11,6 @@ use nostr_sdk::prelude::*;
|
|||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::context_menu::ContextMenuExt;
|
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::modal::ModalButtonProps;
|
||||||
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
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))
|
.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| {
|
.when_some(self.handler, |this, handler| {
|
||||||
this.on_click(move |event, window, cx| {
|
this.on_click(move |event, window, cx| {
|
||||||
handler(event, window, cx);
|
handler(event, window, cx);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
||||||
Task, Window,
|
Task, UniformListScrollHandle, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
@@ -23,6 +23,7 @@ use ui::divider::Divider;
|
|||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{
|
use ui::{
|
||||||
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
|
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<Sidebar> {
|
|||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
|
||||||
/// Image cache
|
/// Image cache
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
@@ -143,6 +145,7 @@ impl Sidebar {
|
|||||||
Self {
|
Self {
|
||||||
name: "Sidebar".into(),
|
name: "Sidebar".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
find_input,
|
find_input,
|
||||||
find_debouncer: DebouncedDelay::new(),
|
find_debouncer: DebouncedDelay::new(),
|
||||||
@@ -206,17 +209,6 @@ impl Sidebar {
|
|||||||
|
|
||||||
/// Search
|
/// Search
|
||||||
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// 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
|
// Get query
|
||||||
let query = self.find_input.read(cx).value();
|
let query = self.find_input.read(cx).value();
|
||||||
|
|
||||||
@@ -228,12 +220,14 @@ impl Sidebar {
|
|||||||
// Block the input until the search completes
|
// Block the input until the search completes
|
||||||
self.set_finding(true, window, cx);
|
self.set_finding(true, window, cx);
|
||||||
|
|
||||||
|
// Create the search task
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let find_users = nostr.read(cx).search(&query, cx);
|
let find_users = nostr.read(cx).search(&query, cx);
|
||||||
|
|
||||||
// Run task in the main thread
|
// Run task in the main thread
|
||||||
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
|
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
let rooms = find_users.await?;
|
let rooms = find_users.await?;
|
||||||
|
|
||||||
// Update the UI with the search results
|
// Update the UI with the search results
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_results(rooms, cx);
|
this.set_results(rooms, cx);
|
||||||
@@ -699,9 +693,11 @@ impl Render for Sidebar {
|
|||||||
this.render_list_items(range, cx)
|
this.render_list_items(range, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.track_scroll(&self.scroll_handle)
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h_full(),
|
.h_full(),
|
||||||
)
|
)
|
||||||
|
.child(Scrollbar::vertical(&self.scroll_handle))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
|
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
|
|||||||
use titlebar::TitleBar;
|
use titlebar::TitleBar;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::popup_menu::PopupMenuExt;
|
use ui::menu::DropdownMenu;
|
||||||
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
|
||||||
|
|
||||||
use crate::panels::greeter;
|
use crate::panels::greeter;
|
||||||
@@ -184,7 +184,7 @@ impl Workspace {
|
|||||||
.caret()
|
.caret()
|
||||||
.compact()
|
.compact()
|
||||||
.transparent()
|
.transparent()
|
||||||
.popup_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.label(profile.name())
|
this.label(profile.name())
|
||||||
.separator()
|
.separator()
|
||||||
.menu("Profile", Box::new(ClosePanel))
|
.menu("Profile", Box::new(ClosePanel))
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
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 nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
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;
|
mod device;
|
||||||
|
|
||||||
@@ -14,8 +13,8 @@ pub use device::*;
|
|||||||
|
|
||||||
const IDENTIFIER: &str = "coop:device";
|
const IDENTIFIER: &str = "coop:device";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||||
@@ -27,11 +26,8 @@ impl Global for GlobalDeviceRegistry {}
|
|||||||
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DeviceRegistry {
|
pub struct DeviceRegistry {
|
||||||
/// Device signer
|
|
||||||
pub device_signer: Entity<Option<Arc<dyn NostrSigner>>>,
|
|
||||||
|
|
||||||
/// Device state
|
/// Device state
|
||||||
pub state: DeviceState,
|
state: DeviceState,
|
||||||
|
|
||||||
/// Device requests
|
/// Device requests
|
||||||
requests: Entity<HashSet<Event>>,
|
requests: Entity<HashSet<Event>>,
|
||||||
@@ -40,7 +36,7 @@ pub struct DeviceRegistry {
|
|||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
|
||||||
/// Subscriptions
|
/// Subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
@@ -55,20 +51,14 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new device registry instance
|
/// Create a new device registry instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
let nip65_state = nostr.read(cx).nip65_state();
|
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());
|
let requests = cx.new(|_| HashSet::default());
|
||||||
|
|
||||||
// Channel for communication between nostr and gpui
|
|
||||||
let (tx, rx) = flume::bounded::<Event>(100);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
let mut tasks = vec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the NIP-65 state
|
// Observe the NIP-65 state
|
||||||
@@ -77,7 +67,7 @@ impl DeviceRegistry {
|
|||||||
RelayState::Idle => {
|
RelayState::Idle => {
|
||||||
this.reset(cx);
|
this.reset(cx);
|
||||||
}
|
}
|
||||||
RelayState::Configured => {
|
RelayState::Configured(_) => {
|
||||||
this.get_announcement(cx);
|
this.get_announcement(cx);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -85,21 +75,57 @@ impl DeviceRegistry {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
// Observe the NIP-17 state
|
this.handle_notifications(cx);
|
||||||
cx.observe(&nip17_state, |this, state, cx| {
|
});
|
||||||
if state.read(cx) == &RelayState::Configured {
|
|
||||||
this.get_messages(cx);
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(
|
Self {
|
||||||
// Handle nostr notifications
|
state: DeviceState::default(),
|
||||||
cx.background_spawn(async move { Self::handle_notifications(&client, &tx).await }),
|
requests,
|
||||||
);
|
tasks: vec![],
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.push(
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let (tx, rx) = flume::bounded::<Event>(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
|
// Update GPUI states
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
while let Ok(event) = rx.recv_async().await {
|
while let Ok(event) = rx.recv_async().await {
|
||||||
@@ -121,137 +147,73 @@ impl DeviceRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
|
||||||
device_signer,
|
|
||||||
requests,
|
|
||||||
state: DeviceState::default(),
|
|
||||||
tasks,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle nostr notifications
|
pub fn state(&self) -> &DeviceState {
|
||||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Event>) -> Result<(), Error> {
|
&self.state
|
||||||
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<Keys, Error> {
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the device state
|
/// Reset the device state
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.state = DeviceState::Initial;
|
||||||
self.requests.update(cx, |this, cx| {
|
self.requests.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.device_signer.update(cx, |this, cx| {
|
|
||||||
*this = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.state = DeviceState::Initial;
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the device signer entity
|
|
||||||
pub fn signer(&self, cx: &App) -> Option<Arc<dyn NostrSigner>> {
|
|
||||||
self.device_signer.read(cx).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the decoupled encryption key for the current user
|
/// Set the decoupled encryption key for the current user
|
||||||
fn set_device_signer<S>(&mut self, signer: S, cx: &mut Context<Self>)
|
fn set_signer<S>(&mut self, new: S, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
S: NostrSigner + 'static,
|
S: NostrSigner + 'static,
|
||||||
{
|
{
|
||||||
self.set_state(DeviceState::Set, cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
self.device_signer.update(cx, |this, cx| {
|
let signer = nostr.read(cx).signer();
|
||||||
*this = Some(Arc::new(signer));
|
|
||||||
cx.notify();
|
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<Self>) {
|
||||||
|
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<Result<(), Error>> = 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
|
/// 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<Self>) {
|
|
||||||
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<Result<(), Error>> = 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::<HashMap<_, _>>();
|
|
||||||
|
|
||||||
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::<HashMap<_, _>>();
|
|
||||||
|
|
||||||
client.subscribe(target).with_id(id).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach_and_log_err(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get device announcement for current user
|
/// Get device announcement for current user
|
||||||
fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -388,7 +305,7 @@ impl DeviceRegistry {
|
|||||||
client.send_event(&event).to_nip65().await?;
|
client.send_event(&event).to_nip65().await?;
|
||||||
|
|
||||||
// Save device keys to the database
|
// Save device keys to the database
|
||||||
Self::set_keys(&client, &secret).await?;
|
set_keys(&client, &secret).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -396,7 +313,7 @@ impl DeviceRegistry {
|
|||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if task.await.is_ok() {
|
if task.await.is_ok() {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_device_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.listen_device_request(cx);
|
this.listen_device_request(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -414,7 +331,7 @@ impl DeviceRegistry {
|
|||||||
let device_pubkey = announcement.public_key();
|
let device_pubkey = announcement.public_key();
|
||||||
|
|
||||||
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Keys, Error>> = 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 {
|
if keys.public_key() != device_pubkey {
|
||||||
return Err(anyhow!("Key mismatch"));
|
return Err(anyhow!("Key mismatch"));
|
||||||
};
|
};
|
||||||
@@ -429,7 +346,7 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_device_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
this.listen_device_request(cx);
|
this.listen_device_request(cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -551,7 +468,7 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(Some(keys)) => {
|
Ok(Some(keys)) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_device_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -595,7 +512,7 @@ impl DeviceRegistry {
|
|||||||
match task.await {
|
match task.await {
|
||||||
Ok(keys) => {
|
Ok(keys) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_device_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -617,7 +534,7 @@ impl DeviceRegistry {
|
|||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
|
|
||||||
// Get device keys
|
// Get device keys
|
||||||
let keys = Self::get_keys(&client).await?;
|
let keys = get_keys(&client).await?;
|
||||||
let secret = keys.secret_key().to_secret_hex();
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
|
|
||||||
// Extract the target public key from the event tags
|
// Extract the target public key from the event tags
|
||||||
@@ -650,3 +567,56 @@ impl DeviceRegistry {
|
|||||||
task.detach();
|
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<Keys, Error> {
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use gpui::{
|
|||||||
SharedString, Window,
|
SharedString, Window,
|
||||||
};
|
};
|
||||||
use ui::button::Button;
|
use ui::button::Button;
|
||||||
use ui::popup_menu::PopupMenu;
|
use ui::menu::PopupMenu;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PanelEvent {
|
pub enum PanelEvent {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
|
||||||
use ui::button::{Button, ButtonVariants as _};
|
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 ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
|
||||||
|
|
||||||
use crate::dock::DockPlacement;
|
use crate::dock::DockPlacement;
|
||||||
@@ -454,7 +454,7 @@ impl TabPanel {
|
|||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.rounded()
|
.rounded()
|
||||||
.popup_menu({
|
.dropdown_menu({
|
||||||
let zoomable = state.zoomable;
|
let zoomable = state.zoomable;
|
||||||
let closable = state.closable;
|
let closable = state.closable;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ impl Global for GlobalPersonRegistry {}
|
|||||||
enum Dispatch {
|
enum Dispatch {
|
||||||
Person(Box<Person>),
|
Person(Box<Person>),
|
||||||
Announcement(Box<Event>),
|
Announcement(Box<Event>),
|
||||||
|
Relays(Box<Event>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Person Registry
|
/// Person Registry
|
||||||
@@ -100,6 +101,9 @@ impl PersonRegistry {
|
|||||||
Dispatch::Announcement(event) => {
|
Dispatch::Announcement(event) => {
|
||||||
this.set_announcement(&event, cx);
|
this.set_announcement(&event, cx);
|
||||||
}
|
}
|
||||||
|
Dispatch::Relays(event) => {
|
||||||
|
this.set_messaging_relays(&event, cx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -140,6 +144,7 @@ impl PersonRegistry {
|
|||||||
/// Handle nostr notifications
|
/// Handle nostr notifications
|
||||||
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed: HashSet<EventId> = HashSet::new();
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
let ClientNotification::Message { message, .. } = notification else {
|
let ClientNotification::Message { message, .. } = notification else {
|
||||||
@@ -148,6 +153,11 @@ impl PersonRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let RelayMessage::Event { event, .. } = message {
|
if let RelayMessage::Event { event, .. } = message {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
if !processed.insert(event.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::Metadata => {
|
Kind::Metadata => {
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
@@ -157,18 +167,24 @@ impl PersonRegistry {
|
|||||||
// Send
|
// Send
|
||||||
tx.send_async(Dispatch::Person(val)).await.ok();
|
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 => {
|
Kind::ContactList => {
|
||||||
let public_keys = event.extract_public_keys();
|
let public_keys = event.extract_public_keys();
|
||||||
|
|
||||||
// Get metadata for all public keys
|
// Get metadata for all public keys
|
||||||
Self::get_metadata(client, public_keys).await.ok();
|
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<RelayUrl> = 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
|
/// Insert batch of persons
|
||||||
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
fn bulk_inserts(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||||
for person in persons.into_iter() {
|
for person in persons.into_iter() {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ pub struct Person {
|
|||||||
|
|
||||||
/// Dekey (NIP-4e) announcement
|
/// Dekey (NIP-4e) announcement
|
||||||
announcement: Option<Announcement>,
|
announcement: Option<Announcement>,
|
||||||
|
|
||||||
|
/// Messaging relays
|
||||||
|
messaging_relays: Vec<RelayUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for Person {
|
impl PartialEq for Person {
|
||||||
@@ -58,6 +61,7 @@ impl Person {
|
|||||||
public_key,
|
public_key,
|
||||||
metadata,
|
metadata,
|
||||||
announcement: None,
|
announcement: None,
|
||||||
|
messaging_relays: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +86,25 @@ impl Person {
|
|||||||
log::info!("Updated announcement for: {}", self.public_key());
|
log::info!("Updated announcement for: {}", self.public_key());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get profile messaging relays
|
||||||
|
pub fn messaging_relays(&self) -> &Vec<RelayUrl> {
|
||||||
|
&self.messaging_relays
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get relay hint for messaging relay list
|
||||||
|
pub fn messaging_relay_hint(&self) -> Option<RelayUrl> {
|
||||||
|
self.messaging_relays.first().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set profile messaging relays
|
||||||
|
pub fn set_messaging_relays<I>(&mut self, public_key: PublicKey, relays: I)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = RelayUrl>,
|
||||||
|
{
|
||||||
|
self.messaging_relays = relays.into_iter().collect();
|
||||||
|
log::info!("Updated messaging relays for: {}", public_key);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get profile avatar
|
/// Get profile avatar
|
||||||
pub fn avatar(&self) -> SharedString {
|
pub fn avatar(&self) -> SharedString {
|
||||||
self.metadata()
|
self.metadata()
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::Hash;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||||
Subscription, Task, Window,
|
Task, Window,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::{AppSettings, AuthMode};
|
use settings::{AppSettings, AuthMode};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{tracker, NostrRegistry};
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
@@ -27,18 +27,12 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication request
|
/// Authentication request
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct AuthRequest {
|
struct AuthRequest {
|
||||||
url: RelayUrl,
|
url: RelayUrl,
|
||||||
challenge: String,
|
challenge: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Hash for AuthRequest {
|
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
||||||
self.challenge.hash(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthRequest {
|
impl AuthRequest {
|
||||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -56,6 +50,12 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum Signal {
|
||||||
|
Auth(Arc<AuthRequest>),
|
||||||
|
Pending((EventId, RelayUrl)),
|
||||||
|
}
|
||||||
|
|
||||||
struct GlobalRelayAuth(Entity<RelayAuth>);
|
struct GlobalRelayAuth(Entity<RelayAuth>);
|
||||||
|
|
||||||
impl Global for GlobalRelayAuth {}
|
impl Global for GlobalRelayAuth {}
|
||||||
@@ -63,11 +63,11 @@ impl Global for GlobalRelayAuth {}
|
|||||||
// Relay authentication
|
// Relay authentication
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RelayAuth {
|
pub struct RelayAuth {
|
||||||
|
/// Pending events waiting for resend after authentication
|
||||||
|
pending_events: HashSet<(EventId, RelayUrl)>,
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
/// Tasks for asynchronous operations
|
||||||
tasks: SmallVec<[Task<()>; 2]>,
|
tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayAuth {
|
impl RelayAuth {
|
||||||
@@ -83,90 +83,104 @@ impl RelayAuth {
|
|||||||
|
|
||||||
/// Create a new relay auth instance
|
/// Create a new relay auth instance
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr = NostrRegistry::global(cx);
|
cx.defer_in(window, |this, window, cx| {
|
||||||
// Channel for communication between nostr and gpui
|
this.handle_notifications(window, cx);
|
||||||
let (tx, rx) = flume::bounded::<Arc<AuthRequest>>(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();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tasks,
|
pending_events: HashSet::default(),
|
||||||
_subscriptions: subscriptions,
|
tasks: smallvec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nostr notifications
|
/// Handle nostr notifications
|
||||||
fn handle_notifications(
|
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
tx: flume::Sender<Arc<AuthRequest>>,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
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::<Signal>(256);
|
||||||
|
|
||||||
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
|
log::info!("Started handling nostr notifications");
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
let mut challenges: HashSet<Cow<'_, str>> = HashSet::default();
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
match notification {
|
if let ClientNotification::Message { relay_url, message } = notification {
|
||||||
ClientNotification::Message { relay_url, message } => {
|
match message {
|
||||||
match message {
|
RelayMessage::Auth { challenge } => {
|
||||||
RelayMessage::Auth { challenge } => {
|
if challenges.insert(challenge.clone()) {
|
||||||
if challenges.insert(challenge.clone()) {
|
let request = Arc::new(AuthRequest::new(challenge, relay_url));
|
||||||
let request = AuthRequest::new(challenge, relay_url);
|
let signal = Signal::Auth(request);
|
||||||
tx.send_async(Arc::new(request)).await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RelayMessage::Ok {
|
|
||||||
event_id, message, ..
|
|
||||||
} => {
|
|
||||||
let msg = MachineReadablePrefix::parse(&message);
|
|
||||||
let mut tracker = tracker().write().await;
|
|
||||||
|
|
||||||
// Handle authentication messages
|
tx.send_async(signal).await.ok();
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
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>) {
|
||||||
|
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<EventId> {
|
||||||
|
let pending_events: Vec<EventId> = 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>) {
|
||||||
|
self.pending_events
|
||||||
|
.retain(|(_, pending_relay)| pending_relay != relay);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle authentication request
|
||||||
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
|
fn handle_auth(&mut self, req: &Arc<AuthRequest>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let settings = AppSettings::global(cx);
|
let settings = AppSettings::global(cx);
|
||||||
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
let trusted_relay = settings.read(cx).trusted_relay(req.url(), cx);
|
||||||
@@ -181,29 +195,25 @@ impl RelayAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Respond to an authentication request.
|
/// Send auth response and wait for confirmation
|
||||||
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
fn auth(&self, req: &Arc<AuthRequest>, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let settings = AppSettings::global(cx);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let req = req.clone();
|
let req = req.clone();
|
||||||
let challenge = req.challenge().to_string();
|
|
||||||
let async_req = req.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = 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
|
// 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?;
|
let event = client.sign_event_builder(builder).await?;
|
||||||
|
|
||||||
// Get the event ID
|
// Get the event ID
|
||||||
let id = event.id;
|
let id = event.id;
|
||||||
|
|
||||||
// Get the relay
|
// Get the relay
|
||||||
let relay = client
|
let relay = client.relay(req.url()).await?.context("Relay not found")?;
|
||||||
.relay(async_req.url())
|
|
||||||
.await?
|
|
||||||
.context("Relay not found")?;
|
|
||||||
|
|
||||||
// Subscribe to notifications
|
// Subscribe to notifications
|
||||||
let mut notifications = relay.notifications();
|
let mut notifications = relay.notifications();
|
||||||
@@ -213,28 +223,35 @@ impl RelayAuth {
|
|||||||
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
log::info!("Sending AUTH event");
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
match notification {
|
match notification {
|
||||||
RelayNotification::Message {
|
RelayNotification::Message {
|
||||||
message: RelayMessage::Ok { event_id, .. },
|
message: RelayMessage::Ok { event_id, .. },
|
||||||
} => {
|
} => {
|
||||||
if id == event_id {
|
if id != event_id {
|
||||||
// Re-subscribe to previous subscription
|
continue;
|
||||||
// relay.resubscribe().await?;
|
|
||||||
|
|
||||||
// Get all pending events that need to be resent
|
|
||||||
let mut tracker = tracker().write().await;
|
|
||||||
let ids: Vec<EventId> = 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(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
RelayNotification::AuthenticationFailed => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -242,22 +259,33 @@ impl RelayAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!("Authentication failed"))
|
Err(anyhow!("Authentication failed"))
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respond to an authentication request.
|
||||||
|
fn response(&self, req: &Arc<AuthRequest>, window: &Window, cx: &Context<Self>) {
|
||||||
|
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| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = task.await;
|
let result = task.await;
|
||||||
let url = req.url();
|
let url = req.url();
|
||||||
|
|
||||||
this.update_in(cx, |_this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
window.clear_notification(challenge, cx);
|
window.clear_notification(challenge, cx);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
// Clear pending events for the authenticated relay
|
||||||
|
this.clear_pending_events(url, cx);
|
||||||
// Save the authenticated relay to automatically authenticate future requests
|
// Save the authenticated relay to automatically authenticate future requests
|
||||||
settings.update(cx, |this, cx| {
|
settings.update(cx, |this, cx| {
|
||||||
this.add_trusted_relay(url, cx);
|
this.add_trusted_relay(url, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.push_notification(format!("{} has been authenticated", url), cx);
|
window.push_notification(format!("{} has been authenticated", url), cx);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -52,10 +52,24 @@ pub enum AuthMode {
|
|||||||
/// Signer kind
|
/// Signer kind
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum SignerKind {
|
pub enum SignerKind {
|
||||||
#[default]
|
|
||||||
Auto,
|
Auto,
|
||||||
|
#[default]
|
||||||
User,
|
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
|
/// Room configuration
|
||||||
@@ -65,6 +79,16 @@ pub struct RoomConfig {
|
|||||||
signer_kind: SignerKind,
|
signer_kind: SignerKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RoomConfig {
|
||||||
|
pub fn backup(&self) -> bool {
|
||||||
|
self.backup
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn signer_kind(&self) -> &SignerKind {
|
||||||
|
&self.signer_kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Settings
|
/// Settings
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
|
|||||||
/// Default search relays
|
/// Default search relays
|
||||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
|
pub const SEARCH_RELAYS: [&str; 1] = ["wss://antiprimal.net"];
|
||||||
|
|
||||||
|
pub const INDEXER_RELAYS: [&str; 1] = ["wss://indexer.coracle.social"];
|
||||||
|
|
||||||
/// Default bootstrap relays
|
/// Default bootstrap relays
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
|
|||||||
@@ -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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>) -> 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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use smol::lock::RwLock;
|
|
||||||
|
|
||||||
static TRACKER: OnceLock<Arc<RwLock<EventTracker>>> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn tracker() -> &'static Arc<RwLock<EventTracker>> {
|
|
||||||
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<EventId>,
|
|
||||||
|
|
||||||
/// 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<EventId> {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,23 +5,21 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
use common::config_dir;
|
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_connect::prelude::*;
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
use nostr_lmdb::NostrLmdb;
|
use nostr_lmdb::NostrLmdb;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
mod constants;
|
mod constants;
|
||||||
mod event;
|
|
||||||
mod nip05;
|
mod nip05;
|
||||||
mod signer;
|
mod signer;
|
||||||
|
|
||||||
pub use constants::*;
|
pub use constants::*;
|
||||||
pub use event::*;
|
|
||||||
pub use nip05::*;
|
pub use nip05::*;
|
||||||
pub use signer::*;
|
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
|
// rustls uses the `aws_lc_rs` provider by default
|
||||||
// This only errors if the default provider has already
|
// This only errors if the default provider has already
|
||||||
// been installed. We can ignore this `Result`.
|
// been installed. We can ignore this `Result`.
|
||||||
@@ -32,10 +30,7 @@ pub fn init(cx: &mut App) {
|
|||||||
// Initialize the tokio runtime
|
// Initialize the tokio runtime
|
||||||
gpui_tokio::init(cx);
|
gpui_tokio::init(cx);
|
||||||
|
|
||||||
// Initialize the event tracker
|
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
|
||||||
let _tracker = tracker();
|
|
||||||
|
|
||||||
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||||
@@ -87,7 +82,7 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new nostr instance
|
/// Create a new nostr instance
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
// Construct the nostr lmdb instance
|
// Construct the nostr lmdb instance
|
||||||
let lmdb = cx.foreground_executor().block_on(async move {
|
let lmdb = cx.foreground_executor().block_on(async move {
|
||||||
NostrLmdb::open(config_dir().join("nostr"))
|
NostrLmdb::open(config_dir().join("nostr"))
|
||||||
@@ -96,13 +91,9 @@ impl NostrRegistry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Construct the nostr signer
|
// 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()));
|
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
|
// Construct the nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
.signer(signer.clone())
|
.signer(signer.clone())
|
||||||
@@ -122,25 +113,33 @@ impl NostrRegistry {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Construct the relay states entity
|
||||||
|
let nip65 = cx.new(|_| RelayState::default());
|
||||||
|
let nip17 = cx.new(|_| RelayState::default());
|
||||||
|
|
||||||
let mut subscriptions = vec![];
|
let mut subscriptions = vec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe the NIP-65 state
|
// Observe the NIP-65 state
|
||||||
cx.observe(&nip65, |this, state, cx| {
|
cx.observe(&nip65, |this, state, cx| {
|
||||||
if state.read(cx).configured() {
|
if state.read(cx).configured().is_some() {
|
||||||
this.get_profile(cx);
|
this.get_profile(cx);
|
||||||
this.get_messaging_relays(cx);
|
this.get_messaging_relays(cx);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
cx.defer(|cx| {
|
subscriptions.push(
|
||||||
let nostr = NostrRegistry::global(cx);
|
// 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
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
nostr.update(cx, |this, cx| {
|
this.connect(cx);
|
||||||
this.connect(cx);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -160,36 +159,35 @@ impl NostrRegistry {
|
|||||||
fn connect(&mut self, cx: &mut Context<Self>) {
|
fn connect(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = 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| {
|
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()
|
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;
|
.await;
|
||||||
|
|
||||||
// Update the state
|
// Update the state
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_connected(cx);
|
||||||
this.get_signer(cx);
|
this.get_signer(cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -244,30 +242,6 @@ impl NostrRegistry {
|
|||||||
cx.notify();
|
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<Option<RelayUrl>> {
|
|
||||||
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<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
|
||||||
return relays.first().cloned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a list of messaging relays with current signer's public key
|
/// Get a list of messaging relays with current signer's public key
|
||||||
pub fn messaging_relays(&self, cx: &App) -> Task<Vec<RelayUrl>> {
|
pub fn messaging_relays(&self, cx: &App) -> Task<Vec<RelayUrl>> {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
@@ -292,12 +266,11 @@ impl NostrRegistry {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|events| events.first_owned())
|
.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();
|
.unwrap_or_default();
|
||||||
|
|
||||||
for relay in relays.iter() {
|
for relay in relays.iter() {
|
||||||
client.add_relay(relay).await.ok();
|
client.add_relay(relay).and_connect().await.ok();
|
||||||
client.connect_relay(relay).await.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relays
|
relays
|
||||||
@@ -305,15 +278,36 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reset all relay states
|
/// Reset all relay states
|
||||||
pub fn reset_relay_states(&mut self, cx: &mut Context<Self>) {
|
pub fn reset_relays(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client();
|
||||||
|
|
||||||
self.nip65.update(cx, |this, cx| {
|
self.nip65.update(cx, |this, cx| {
|
||||||
*this = RelayState::default();
|
*this = RelayState::default();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.nip17.update(cx, |this, cx| {
|
self.nip17.update(cx, |this, cx| {
|
||||||
*this = RelayState::default();
|
*this = RelayState::default();
|
||||||
cx.notify();
|
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
|
/// Set the signer for the nostr client and verify the public key
|
||||||
@@ -329,6 +323,9 @@ impl NostrRegistry {
|
|||||||
// Update signer
|
// Update signer
|
||||||
signer.switch(new, owned).await;
|
signer.switch(new, owned).await;
|
||||||
|
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
client.unsubscribe_all().await?;
|
||||||
|
|
||||||
// Verify signer
|
// Verify signer
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = client.signer().context("Signer not found")?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
@@ -343,89 +340,14 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
// Update states
|
// Update states
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.reset_relay_states(cx);
|
this.reset_relays(cx);
|
||||||
|
this.get_relay_list(cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
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::<HashMap<_, _>>();
|
|
||||||
|
|
||||||
client.subscribe(target).close_on(opts).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// Get or create a new app keys
|
|
||||||
fn create_or_init_app_keys() -> Result<Keys, Error> {
|
|
||||||
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
|
// Get relay list for current user
|
||||||
fn get_relay_list(&mut self, cx: &mut Context<Self>) {
|
fn get_relay_list(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
@@ -454,14 +376,13 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
let mut stream = client
|
let mut stream = client
|
||||||
.stream_events(target)
|
.stream_events(target)
|
||||||
|
.policy(ReqExitPolicy::WaitForEvents(1))
|
||||||
.timeout(Duration::from_secs(TIMEOUT))
|
.timeout(Duration::from_secs(TIMEOUT))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
while let Some((_url, res)) = stream.next().await {
|
while let Some((_url, res)) = stream.next().await {
|
||||||
match res {
|
match res {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
log::info!("Received relay list event: {event:?}");
|
|
||||||
|
|
||||||
// Construct a filter to continuously receive relay list events
|
// Construct a filter to continuously receive relay list events
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::RelayList)
|
.kind(Kind::RelayList)
|
||||||
@@ -477,7 +398,7 @@ impl NostrRegistry {
|
|||||||
// Subscribe to the relay list events
|
// Subscribe to the relay list events
|
||||||
client.subscribe(target).await?;
|
client.subscribe(target).await?;
|
||||||
|
|
||||||
return Ok(RelayState::Configured);
|
return Ok(RelayState::Configured(Box::new(event)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to receive relay list event: {e}");
|
log::error!("Failed to receive relay list event: {e}");
|
||||||
@@ -531,14 +452,13 @@ impl NostrRegistry {
|
|||||||
// Stream events from the write relays
|
// Stream events from the write relays
|
||||||
let mut stream = client
|
let mut stream = client
|
||||||
.stream_events(filter)
|
.stream_events(filter)
|
||||||
|
.policy(ReqExitPolicy::WaitForEvents(1))
|
||||||
.timeout(Duration::from_secs(TIMEOUT))
|
.timeout(Duration::from_secs(TIMEOUT))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
while let Some((_url, res)) = stream.next().await {
|
while let Some((_url, res)) = stream.next().await {
|
||||||
match res {
|
match res {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
log::info!("Received messaging relays event: {event:?}");
|
|
||||||
|
|
||||||
// Construct a filter to continuously receive relay list events
|
// Construct a filter to continuously receive relay list events
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::InboxRelays)
|
.kind(Kind::InboxRelays)
|
||||||
@@ -548,7 +468,7 @@ impl NostrRegistry {
|
|||||||
// Subscribe to the relay list events
|
// Subscribe to the relay list events
|
||||||
client.subscribe(filter).await?;
|
client.subscribe(filter).await?;
|
||||||
|
|
||||||
return Ok(RelayState::Configured);
|
return Ok(RelayState::Configured(Box::new(event)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to get messaging relays: {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<Self>) {
|
||||||
|
let client = self.client();
|
||||||
|
let signer = self.signer();
|
||||||
|
let relay_urls: Vec<RelayUrl> = nip17::extract_relay_list(relay_list).cloned().collect();
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = 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
|
/// Get profile and contact list for current user
|
||||||
fn get_profile(&mut self, cx: &mut Context<Self>) {
|
fn get_profile(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
@@ -648,6 +603,7 @@ impl NostrRegistry {
|
|||||||
fn set_default_signer(&mut self, cx: &mut Context<Self>) {
|
fn set_default_signer(&mut self, cx: &mut Context<Self>) {
|
||||||
let client = self.client();
|
let client = self.client();
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
|
let async_keys = keys.clone();
|
||||||
|
|
||||||
// Create a write credential task
|
// Create a write credential task
|
||||||
let write_credential = cx.write_credentials(
|
let write_credential = cx.write_credentials(
|
||||||
@@ -656,21 +612,18 @@ impl NostrRegistry {
|
|||||||
&keys.secret_key().to_secret_bytes(),
|
&keys.secret_key().to_secret_bytes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the signer
|
|
||||||
self.set_signer(keys, false, cx);
|
|
||||||
|
|
||||||
// Set the creating signer status
|
// Set the creating signer status
|
||||||
self.set_creating_signer(true, cx);
|
self.set_creating_signer(true, cx);
|
||||||
|
|
||||||
// Run async tasks in background
|
// Run async tasks in background
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let signer = client.signer().context("Signer not found")?;
|
let signer = async_keys.into_nostr_signer();
|
||||||
|
|
||||||
// Get default relay list
|
// Get default relay list
|
||||||
let relay_list = default_relay_list();
|
let relay_list = default_relay_list();
|
||||||
|
|
||||||
// Publish relay list event
|
// 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
|
client
|
||||||
.send_event(&event)
|
.send_event(&event)
|
||||||
.broadcast()
|
.broadcast()
|
||||||
@@ -683,33 +636,36 @@ impl NostrRegistry {
|
|||||||
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
let metadata = Metadata::new().display_name(&name).picture(avatar);
|
||||||
|
|
||||||
// Publish metadata event
|
// Publish metadata event
|
||||||
let event = EventBuilder::metadata(&metadata).sign(signer).await?;
|
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||||
client
|
client
|
||||||
.send_event(&event)
|
.send_event(&event)
|
||||||
.broadcast()
|
.broadcast()
|
||||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Construct the default contact list
|
// Construct the default contact list
|
||||||
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())];
|
||||||
|
|
||||||
// Publish contact list event
|
// Publish contact list event
|
||||||
let event = EventBuilder::contact_list(contacts).sign(signer).await?;
|
let event = EventBuilder::contact_list(contacts).sign(&signer).await?;
|
||||||
client
|
client
|
||||||
.send_event(&event)
|
.send_event(&event)
|
||||||
.broadcast()
|
.broadcast()
|
||||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Construct the default messaging relay list
|
// Construct the default messaging relay list
|
||||||
let relays = default_messaging_relays();
|
let relays = default_messaging_relays();
|
||||||
|
|
||||||
// Publish messaging relay list event
|
// 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
|
client
|
||||||
.send_event(&event)
|
.send_event(&event)
|
||||||
.to_nip65()
|
.to_nip65()
|
||||||
.ok_timeout(Duration::from_secs(TIMEOUT))
|
.ok_timeout(Duration::from_secs(TIMEOUT))
|
||||||
|
.ack_policy(AckPolicy::none())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Write user's credentials to the system keyring
|
// Write user's credentials to the system keyring
|
||||||
@@ -724,7 +680,7 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_creating_signer(false, cx);
|
this.set_creating_signer(false, cx);
|
||||||
this.get_relay_list(cx);
|
this.set_signer(keys, false, cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -743,7 +699,6 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(keys, false, cx);
|
this.set_signer(keys, false, cx);
|
||||||
this.get_relay_list(cx);
|
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -786,7 +741,6 @@ impl NostrRegistry {
|
|||||||
Ok(signer) => {
|
Ok(signer) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.set_signer(signer, true, cx);
|
this.set_signer(signer, true, cx);
|
||||||
this.get_relay_list(cx);
|
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -882,9 +836,30 @@ impl NostrRegistry {
|
|||||||
let client = self.client();
|
let client = self.client();
|
||||||
let query = query.to_string();
|
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 {
|
cx.background_spawn(async move {
|
||||||
let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
|
let mut results: Vec<PublicKey> = 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
|
// Construct the filter for the search query
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.search(query.to_lowercase())
|
.search(query.to_lowercase())
|
||||||
@@ -980,6 +955,36 @@ impl NostrRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get or create a new app keys
|
||||||
|
fn get_or_init_app_keys() -> Result<Keys, Error> {
|
||||||
|
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<RelayMetadata>)> {
|
fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||||
vec![
|
vec![
|
||||||
(
|
(
|
||||||
@@ -991,7 +996,7 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
|||||||
Some(RelayMetadata::Write),
|
Some(RelayMetadata::Write),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
RelayUrl::parse("wss://relay.primal.net/").unwrap(),
|
RelayUrl::parse("wss://relay.damus.io/").unwrap(),
|
||||||
Some(RelayMetadata::Read),
|
Some(RelayMetadata::Read),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1003,18 +1008,18 @@ fn default_relay_list() -> Vec<(RelayUrl, Option<RelayMetadata>)> {
|
|||||||
|
|
||||||
fn default_messaging_relays() -> Vec<RelayUrl> {
|
fn default_messaging_relays() -> Vec<RelayUrl> {
|
||||||
vec![
|
vec![
|
||||||
RelayUrl::parse("wss://auth.nostr1.com/").unwrap(),
|
//RelayUrl::parse("wss://auth.nostr1.com/").unwrap(),
|
||||||
RelayUrl::parse("wss://nip17.com/").unwrap(),
|
RelayUrl::parse("wss://nip17.com/").unwrap(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub enum RelayState {
|
pub enum RelayState {
|
||||||
#[default]
|
#[default]
|
||||||
Idle,
|
Idle,
|
||||||
Checking,
|
Checking,
|
||||||
NotConfigured,
|
NotConfigured,
|
||||||
Configured,
|
Configured(Box<Event>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayState {
|
impl RelayState {
|
||||||
@@ -1030,8 +1035,11 @@ impl RelayState {
|
|||||||
matches!(self, RelayState::NotConfigured)
|
matches!(self, RelayState::NotConfigured)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn configured(&self) -> bool {
|
pub fn configured(&self) -> Option<&Event> {
|
||||||
matches!(self, RelayState::Configured)
|
match self {
|
||||||
|
RelayState::Configured(event) => Some(event),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ use smol::lock::RwLock;
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CoopSigner {
|
pub struct CoopSigner {
|
||||||
|
/// User's signer
|
||||||
signer: RwLock<Arc<dyn NostrSigner>>,
|
signer: RwLock<Arc<dyn NostrSigner>>,
|
||||||
|
|
||||||
/// Signer's public key
|
/// User's signer public key
|
||||||
signer_pkey: RwLock<Option<PublicKey>>,
|
signer_pkey: RwLock<Option<PublicKey>>,
|
||||||
|
|
||||||
|
/// Specific signer for encryption purposes
|
||||||
|
encryption_signer: RwLock<Option<Arc<dyn NostrSigner>>>,
|
||||||
|
|
||||||
/// Whether coop is creating a new identity
|
/// Whether coop is creating a new identity
|
||||||
creating: AtomicBool,
|
creating: AtomicBool,
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ impl CoopSigner {
|
|||||||
Self {
|
Self {
|
||||||
signer: RwLock::new(signer.into_nostr_signer()),
|
signer: RwLock::new(signer.into_nostr_signer()),
|
||||||
signer_pkey: RwLock::new(None),
|
signer_pkey: RwLock::new(None),
|
||||||
|
encryption_signer: RwLock::new(None),
|
||||||
creating: AtomicBool::new(false),
|
creating: AtomicBool::new(false),
|
||||||
owned: AtomicBool::new(false),
|
owned: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
@@ -40,6 +45,11 @@ impl CoopSigner {
|
|||||||
self.signer.read().await.clone()
|
self.signer.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the encryption signer.
|
||||||
|
pub async fn get_encryption_signer(&self) -> Option<Arc<dyn NostrSigner>> {
|
||||||
|
self.encryption_signer.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get public key
|
/// Get public key
|
||||||
pub fn public_key(&self) -> Option<PublicKey> {
|
pub fn public_key(&self) -> Option<PublicKey> {
|
||||||
self.signer_pkey.read_blocking().to_owned()
|
self.signer_pkey.read_blocking().to_owned()
|
||||||
@@ -64,6 +74,7 @@ impl CoopSigner {
|
|||||||
let public_key = new_signer.get_public_key().await.ok();
|
let public_key = new_signer.get_public_key().await.ok();
|
||||||
let mut signer = self.signer.write().await;
|
let mut signer = self.signer.write().await;
|
||||||
let mut signer_pkey = self.signer_pkey.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
|
// Switch to the new signer
|
||||||
*signer = new_signer;
|
*signer = new_signer;
|
||||||
@@ -71,9 +82,21 @@ impl CoopSigner {
|
|||||||
// Update the public key
|
// Update the public key
|
||||||
*signer_pkey = public_key;
|
*signer_pkey = public_key;
|
||||||
|
|
||||||
|
// Reset the encryption signer
|
||||||
|
*encryption_signer = None;
|
||||||
|
|
||||||
// Update the owned flag
|
// Update the owned flag
|
||||||
self.owned.store(owned, Ordering::SeqCst);
|
self.owned.store(owned, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the encryption signer.
|
||||||
|
pub async fn set_encryption_signer<T>(&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 {
|
impl NostrSigner for CoopSigner {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
pub enum ScrollbarMode {
|
pub enum ScrollbarMode {
|
||||||
#[default]
|
#[default]
|
||||||
Scrolling,
|
|
||||||
Hover,
|
Hover,
|
||||||
|
Scrolling,
|
||||||
Always,
|
Always,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
333
crates/ui/src/anchored.rs
Normal file
333
crates/ui/src/anchored.rs
Normal file
@@ -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<Point<Pixels>>,
|
||||||
|
position_mode: AnchoredPositionMode,
|
||||||
|
offset: Option<Point<Pixels>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Pixels>) -> 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<Pixels>) -> 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<Edges<Pixels>>) -> Self {
|
||||||
|
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParentElement for Anchored {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
|
self.children.extend(elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for Anchored {
|
||||||
|
type PrepaintState = ();
|
||||||
|
type RequestLayoutState = AnchoredState;
|
||||||
|
|
||||||
|
fn id(&self) -> Option<gpui::ElementId> {
|
||||||
|
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::<SmallVec<_>>();
|
||||||
|
|
||||||
|
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<Pixels>,
|
||||||
|
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<Pixels> = (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<Pixels>,
|
||||||
|
_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<Pixels>),
|
||||||
|
/// 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<Point<Pixels>>,
|
||||||
|
anchor_corner: Anchor,
|
||||||
|
size: Size<Pixels>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
offset: Option<Point<Pixels>>,
|
||||||
|
) -> (Point<Pixels>, Bounds<Pixels>) {
|
||||||
|
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<Pixels>,
|
||||||
|
size: Size<Pixels>,
|
||||||
|
) -> Bounds<Pixels> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnyElement> {
|
|
||||||
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<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
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<T: DropdownItem> DropdownDelegate for Vec<T> {
|
|
||||||
type Item = T;
|
|
||||||
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(&self, ix: usize) -> Option<&Self::Item> {
|
|
||||||
self.as_slice().get(ix)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn position<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
V: PartialEq,
|
|
||||||
{
|
|
||||||
self.iter().position(|v| v.value() == value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
|
|
||||||
delegate: D,
|
|
||||||
dropdown: WeakEntity<DropdownState<D>>,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> ListDelegate for DropdownListDelegate<D>
|
|
||||||
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<List<Self>>,
|
|
||||||
) -> Option<Self::Item> {
|
|
||||||
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<List<Self>>) {
|
|
||||||
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<List<Self>>) {
|
|
||||||
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<List<Self>>,
|
|
||||||
) -> 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<usize>,
|
|
||||||
_: &mut Window,
|
|
||||||
_: &mut Context<List<Self>>,
|
|
||||||
) {
|
|
||||||
self.selected_index = ix;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_empty(&self, window: &mut Window, cx: &mut Context<List<Self>>) -> 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<D: DropdownDelegate + 'static> {
|
|
||||||
Confirm(Option<<D::Item as DropdownItem>::Value>),
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownStateEmpty = Option<Box<dyn Fn(&Window, &App) -> AnyElement>>;
|
|
||||||
|
|
||||||
/// State of the [`Dropdown`].
|
|
||||||
pub struct DropdownState<D: DropdownDelegate + 'static> {
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
list: Entity<List<DropdownListDelegate<D>>>,
|
|
||||||
size: Size,
|
|
||||||
empty: DropdownStateEmpty,
|
|
||||||
/// Store the bounds of the input
|
|
||||||
bounds: Bounds<Pixels>,
|
|
||||||
open: bool,
|
|
||||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Dropdown element.
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
|
||||||
id: ElementId,
|
|
||||||
state: Entity<DropdownState<D>>,
|
|
||||||
size: Size,
|
|
||||||
icon: Option<Icon>,
|
|
||||||
cleanable: bool,
|
|
||||||
placeholder: Option<SharedString>,
|
|
||||||
title_prefix: Option<SharedString>,
|
|
||||||
empty: Option<AnyElement>,
|
|
||||||
width: Length,
|
|
||||||
menu_width: Length,
|
|
||||||
disabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SearchableVec<T> {
|
|
||||||
items: Vec<T>,
|
|
||||||
matched_items: Vec<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DropdownItem + Clone> SearchableVec<T> {
|
|
||||||
pub fn new(items: impl Into<Vec<T>>) -> Self {
|
|
||||||
let items = items.into();
|
|
||||||
Self {
|
|
||||||
items: items.clone(),
|
|
||||||
matched_items: items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
|
|
||||||
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<V>(&self, value: &V) -> Option<usize>
|
|
||||||
where
|
|
||||||
Self::Item: DropdownItem<Value = V>,
|
|
||||||
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<Vec<SharedString>> for SearchableVec<SharedString> {
|
|
||||||
fn from(items: Vec<SharedString>) -> Self {
|
|
||||||
Self {
|
|
||||||
items: items.clone(),
|
|
||||||
matched_items: items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(
|
|
||||||
delegate: D,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> 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<E, F>(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<usize>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
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: &<D::Item as DropdownItem>::Value,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) where
|
|
||||||
<<D as DropdownDelegate>::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<usize> {
|
|
||||||
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<&<D::Item as DropdownItem>::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<Self>) {
|
|
||||||
// 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<Self>) {
|
|
||||||
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<Self>) {
|
|
||||||
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<Self>) {
|
|
||||||
// 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<Self>) {
|
|
||||||
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<Self>) {
|
|
||||||
if !self.open {
|
|
||||||
cx.propagate();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.open = false;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
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<Self>)
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
self.list.update(cx, |list, _| {
|
|
||||||
list.delegate_mut().delegate = items;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Render for DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
Empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(state: &Entity<DropdownState<D>>) -> 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<Length>) -> 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<Length>) -> 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<SharedString>) -> 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<Icon>) -> 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<SharedString>) -> 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<D> Sizable for Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate + 'static,
|
|
||||||
{
|
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
|
||||||
self.size = size.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> EventEmitter<DropdownEvent<D>> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
|
||||||
impl<D> EventEmitter<DismissEvent> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
|
||||||
impl<D> Focusable for DropdownState<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate,
|
|
||||||
{
|
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
||||||
if self.open {
|
|
||||||
self.list.focus_handle(cx)
|
|
||||||
} else {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl<D> Focusable for Dropdown<D>
|
|
||||||
where
|
|
||||||
D: DropdownDelegate,
|
|
||||||
{
|
|
||||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
||||||
self.state.focus_handle(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> RenderOnce for Dropdown<D>
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
294
crates/ui/src/geometry.rs
Normal file
294
crates/ui/src/geometry.rs
Normal file
@@ -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<Corner> 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<Anchor> 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<Pixels>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LengthExt for Length {
|
||||||
|
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
|
||||||
|
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<T: Clone + Debug + Default + PartialEq> {
|
||||||
|
/// 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<T> Edges<T>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ pub enum IconName {
|
|||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
PanelBottom,
|
PanelBottom,
|
||||||
PanelBottomOpen,
|
PanelBottomOpen,
|
||||||
|
PaperPlaneFill,
|
||||||
Warning,
|
Warning,
|
||||||
WindowClose,
|
WindowClose,
|
||||||
WindowMaximize,
|
WindowMaximize,
|
||||||
@@ -106,6 +107,7 @@ impl IconName {
|
|||||||
Self::PanelRightOpen => "icons/panel-right-open.svg",
|
Self::PanelRightOpen => "icons/panel-right-open.svg",
|
||||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||||
|
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||||
Self::Warning => "icons/warning.svg",
|
Self::Warning => "icons/warning.svg",
|
||||||
Self::WindowClose => "icons/window-close.svg",
|
Self::WindowClose => "icons/window-close.svg",
|
||||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||||
|
|||||||
69
crates/ui/src/index_path.rs
Normal file
69
crates/ui/src/index_path.rs
Normal file
@@ -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<IndexPath> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
pub use anchored::*;
|
||||||
pub use element_ext::ElementExt;
|
pub use element_ext::ElementExt;
|
||||||
pub use event::InteractiveElementExt;
|
pub use event::InteractiveElementExt;
|
||||||
pub use focusable::FocusableCycle;
|
pub use focusable::FocusableCycle;
|
||||||
|
pub use geometry::*;
|
||||||
pub use icon::*;
|
pub use icon::*;
|
||||||
|
pub use index_path::IndexPath;
|
||||||
pub use kbd::*;
|
pub use kbd::*;
|
||||||
pub use menu::{context_menu, popup_menu};
|
|
||||||
pub use root::{window_paddings, Root};
|
pub use root::{window_paddings, Root};
|
||||||
pub use styled::*;
|
pub use styled::*;
|
||||||
pub use window_ext::*;
|
pub use window_ext::*;
|
||||||
@@ -16,7 +18,6 @@ pub mod avatar;
|
|||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
pub mod divider;
|
pub mod divider;
|
||||||
pub mod dropdown;
|
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod indicator;
|
pub mod indicator;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
@@ -30,10 +31,13 @@ pub mod skeleton;
|
|||||||
pub mod switch;
|
pub mod switch;
|
||||||
pub mod tooltip;
|
pub mod tooltip;
|
||||||
|
|
||||||
|
mod anchored;
|
||||||
mod element_ext;
|
mod element_ext;
|
||||||
mod event;
|
mod event;
|
||||||
mod focusable;
|
mod focusable;
|
||||||
|
mod geometry;
|
||||||
mod icon;
|
mod icon;
|
||||||
|
mod index_path;
|
||||||
mod kbd;
|
mod kbd;
|
||||||
mod root;
|
mod root;
|
||||||
mod styled;
|
mod styled;
|
||||||
@@ -44,7 +48,6 @@ mod window_ext;
|
|||||||
/// This must be called before using any of the UI components.
|
/// This must be called before using any of the UI components.
|
||||||
/// You can initialize the UI module at your application's entry point.
|
/// You can initialize the UI module at your application's entry point.
|
||||||
pub fn init(cx: &mut gpui::App) {
|
pub fn init(cx: &mut gpui::App) {
|
||||||
dropdown::init(cx);
|
|
||||||
input::init(cx);
|
input::init(cx);
|
||||||
list::init(cx);
|
list::init(cx);
|
||||||
modal::init(cx);
|
modal::init(cx);
|
||||||
|
|||||||
221
crates/ui/src/list/cache.rs
Normal file
221
crates/ui/src/list/cache.rs
Normal file
@@ -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<Pixels>,
|
||||||
|
pub(crate) section_header_size: Size<Pixels>,
|
||||||
|
pub(crate) section_footer_size: Size<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<RowEntry>>,
|
||||||
|
pub(crate) items_count: usize,
|
||||||
|
/// The sections, the item is number of rows in each section.
|
||||||
|
pub(crate) sections: Rc<Vec<usize>>,
|
||||||
|
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
|
measured_size: MeasuredEntrySize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowsCache {
|
||||||
|
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
|
||||||
|
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<usize> {
|
||||||
|
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>) -> 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>) -> 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<F>(
|
||||||
|
&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;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
crates/ui/src/list/delegate.rs
Normal file
171
crates/ui/src/list/delegate.rs
Normal file
@@ -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<ListState<Self>>,
|
||||||
|
) -> 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<ListState<Self>>,
|
||||||
|
) -> Option<Self::Item>;
|
||||||
|
|
||||||
|
/// 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<ListState<Self>>,
|
||||||
|
) -> Option<impl IntoElement> {
|
||||||
|
None::<AnyElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ListState<Self>>,
|
||||||
|
) -> Option<impl IntoElement> {
|
||||||
|
None::<AnyElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a Element to show when list is empty.
|
||||||
|
fn render_empty(
|
||||||
|
&mut self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<ListState<Self>>,
|
||||||
|
) -> 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<ListState<Self>>,
|
||||||
|
) -> Option<AnyElement> {
|
||||||
|
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<ListState<Self>>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the selected index, just store the ix, don't confirm.
|
||||||
|
fn set_selected_index(
|
||||||
|
&mut self,
|
||||||
|
ix: Option<IndexPath>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<ListState<Self>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Set the index of the item that has been right clicked.
|
||||||
|
fn set_right_clicked_index(
|
||||||
|
&mut self,
|
||||||
|
ix: Option<IndexPath>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<ListState<Self>>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ListState<Self>>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the selection, e.g.: Pressed ESC.
|
||||||
|
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
|
||||||
|
|
||||||
|
/// 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<ListState<Self>>) {}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,57 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton,
|
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
|
||||||
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled,
|
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
|
||||||
Window,
|
StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use theme::ActiveTheme;
|
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<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>;
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
type OnMouseEnter = Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>;
|
enum ListItemMode {
|
||||||
type Suffix = Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>;
|
#[default]
|
||||||
|
Entry,
|
||||||
|
Separator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListItemMode {
|
||||||
|
#[inline]
|
||||||
|
fn is_separator(&self) -> bool {
|
||||||
|
matches!(self, ListItemMode::Separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct ListItem {
|
pub struct ListItem {
|
||||||
base: Stateful<Div>,
|
base: Stateful<Div>,
|
||||||
|
mode: ListItemMode,
|
||||||
|
style: StyleRefinement,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
|
secondary_selected: bool,
|
||||||
confirmed: bool,
|
confirmed: bool,
|
||||||
check_icon: Option<Icon>,
|
check_icon: Option<Icon>,
|
||||||
on_click: OnClick,
|
#[allow(clippy::type_complexity)]
|
||||||
on_mouse_enter: OnMouseEnter,
|
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||||
suffix: Suffix,
|
#[allow(clippy::type_complexity)]
|
||||||
|
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
|
||||||
children: SmallVec<[AnyElement; 2]>,
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListItem {
|
impl ListItem {
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
let id: ElementId = id.into();
|
let id: ElementId = id.into();
|
||||||
|
|
||||||
Self {
|
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,
|
disabled: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
secondary_selected: false,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
on_mouse_enter: 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.
|
/// Set to show check icon, default is None.
|
||||||
pub fn check_icon(mut self, icon: IconName) -> Self {
|
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||||
self.check_icon = Some(Icon::new(icon));
|
self.check_icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +135,16 @@ impl Selectable for ListItem {
|
|||||||
fn is_selected(&self) -> bool {
|
fn is_selected(&self) -> bool {
|
||||||
self.selected
|
self.selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn secondary_selected(mut self, selected: bool) -> Self {
|
||||||
|
self.secondary_selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Styled for ListItem {
|
impl Styled for ListItem {
|
||||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
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 {
|
impl RenderOnce for ListItem {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
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
|
self.base
|
||||||
|
.relative()
|
||||||
|
.gap_x_1()
|
||||||
|
.py_1()
|
||||||
|
.px_3()
|
||||||
|
.text_base()
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().text)
|
||||||
.relative()
|
.relative()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.when_some(self.on_click, |this, on_click| {
|
.refine_style(&self.style)
|
||||||
if !self.disabled {
|
.when(is_selectable, |this| {
|
||||||
this.cursor_pointer()
|
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
|
||||||
.on_mouse_down(MouseButton::Left, move |_, _window, cx| {
|
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||||
cx.stop_propagation();
|
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
||||||
})
|
})
|
||||||
.on_click(on_click)
|
.when(!is_active, |this| {
|
||||||
} else {
|
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
this
|
})
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.when(is_active, |this| this.bg(cx.theme().element_active))
|
.when(!is_selectable, |this| {
|
||||||
.when(!is_active && !self.disabled, |this| {
|
this.text_color(cx.theme().text_muted)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -177,5 +210,17 @@ impl RenderOnce for ListItem {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
|
.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
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem {
|
|||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(Skeleton::new().h_5().w_48().max_w_full())
|
.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()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
|
pub(crate) mod cache;
|
||||||
|
mod delegate;
|
||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
mod list;
|
mod list;
|
||||||
mod list_item;
|
mod list_item;
|
||||||
mod loading;
|
mod loading;
|
||||||
|
mod separator_item;
|
||||||
|
|
||||||
|
pub use delegate::*;
|
||||||
pub use list::*;
|
pub use list::*;
|
||||||
pub use list_item::*;
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
50
crates/ui/src/list/separator_item.rs
Normal file
50
crates/ui/src/list/separator_item.rs
Normal file
@@ -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<Item = AnyElement>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
||||||
Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render,
|
Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu,
|
||||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
||||||
use crate::button::{Button, ButtonVariants};
|
use crate::button::{Button, ButtonVariants};
|
||||||
use crate::popup_menu::PopupMenu;
|
use crate::menu::PopupMenu;
|
||||||
use crate::{h_flex, Selectable, Sizable};
|
use crate::{h_flex, Selectable, Sizable};
|
||||||
|
|
||||||
const CONTEXT: &str = "AppMenuBar";
|
const CONTEXT: &str = "AppMenuBar";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||||
@@ -22,67 +23,74 @@ pub fn init(cx: &mut App) {
|
|||||||
/// The application menu bar, for Windows and Linux.
|
/// The application menu bar, for Windows and Linux.
|
||||||
pub struct AppMenuBar {
|
pub struct AppMenuBar {
|
||||||
menus: Vec<Entity<AppMenu>>,
|
menus: Vec<Entity<AppMenu>>,
|
||||||
selected_ix: Option<usize>,
|
selected_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppMenuBar {
|
impl AppMenuBar {
|
||||||
/// Create a new app menu bar.
|
/// Create a new app menu bar.
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
let menu_bar = cx.entity();
|
let mut this = Self {
|
||||||
let menus = cx
|
selected_index: None,
|
||||||
.get_menus()
|
menus: Vec::new(),
|
||||||
.unwrap_or_default()
|
};
|
||||||
.iter()
|
this.reload(cx);
|
||||||
.enumerate()
|
this
|
||||||
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
selected_ix: None,
|
|
||||||
menus,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
/// Reload the menus from the app.
|
||||||
let Some(selected_ix) = self.selected_ix else {
|
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
let Some(selected_index) = self.selected_index else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_ix = if selected_ix == 0 {
|
let new_ix = if selected_index == 0 {
|
||||||
self.menus.len().saturating_sub(1)
|
self.menus.len().saturating_sub(1)
|
||||||
} else {
|
} 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<Self>) {
|
fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(selected_ix) = self.selected_ix else {
|
let Some(selected_index) = self.selected_index else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_ix = if selected_ix + 1 >= self.menus.len() {
|
let new_ix = if selected_index + 1 >= self.menus.len() {
|
||||||
0
|
0
|
||||||
} else {
|
} 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>) {
|
fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.set_selected_ix(None, window, cx);
|
self.set_selected_index(None, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_selected_ix(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
|
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.selected_ix = ix;
|
self.selected_index = ix;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn has_activated_menu(&self) -> bool {
|
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()
|
h_flex()
|
||||||
.id("app-menu-bar")
|
.id("app-menu-bar")
|
||||||
.key_context(CONTEXT)
|
.key_context(CONTEXT)
|
||||||
.on_action(cx.listener(Self::move_left))
|
.on_action(cx.listener(Self::on_move_left))
|
||||||
.on_action(cx.listener(Self::move_right))
|
.on_action(cx.listener(Self::on_move_right))
|
||||||
.on_action(cx.listener(Self::cancel))
|
.on_action(cx.listener(Self::on_cancel))
|
||||||
.size_full()
|
.size_full()
|
||||||
.gap_x_1()
|
.gap_x_1()
|
||||||
.overflow_x_scroll()
|
.overflow_x_scroll()
|
||||||
@@ -117,7 +125,6 @@ impl AppMenu {
|
|||||||
ix: usize,
|
ix: usize,
|
||||||
menu: &OwnedMenu,
|
menu: &OwnedMenu,
|
||||||
menu_bar: Entity<AppMenuBar>,
|
menu_bar: Entity<AppMenuBar>,
|
||||||
_: &mut Window,
|
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Entity<Self> {
|
) -> Entity<Self> {
|
||||||
let name = menu.name.clone();
|
let name = menu.name.clone();
|
||||||
@@ -173,7 +180,7 @@ impl AppMenu {
|
|||||||
self._subscription.take();
|
self._subscription.take();
|
||||||
self.popup_menu.take();
|
self.popup_menu.take();
|
||||||
self.menu_bar.update(cx, |state, cx| {
|
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,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
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| {
|
self.menu_bar.update(cx, |state, cx| {
|
||||||
let new_ix = if is_selected { None } else { Some(self.ix) };
|
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| {
|
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 {
|
impl Render for AppMenu {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let menu_bar = self.menu_bar.read(cx);
|
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()
|
div()
|
||||||
.id(self.ix)
|
.id(self.ix)
|
||||||
@@ -219,10 +226,15 @@ impl Render for AppMenu {
|
|||||||
Button::new("menu")
|
Button::new("menu")
|
||||||
.small()
|
.small()
|
||||||
.py_0p5()
|
.py_0p5()
|
||||||
.xsmall()
|
.compact()
|
||||||
.ghost()
|
.ghost()
|
||||||
.label(self.name.clone())
|
.label(self.name.clone())
|
||||||
.selected(is_selected)
|
.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_click(cx.listener(Self::handle_trigger_click)),
|
||||||
)
|
)
|
||||||
.on_hover(cx.listener(Self::handle_hover))
|
.on_hover(cx.listener(Self::handle_hover))
|
||||||
|
|||||||
@@ -3,49 +3,66 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element,
|
anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||||
ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement,
|
ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
|
||||||
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful,
|
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
|
||||||
Style, Subscription, Window,
|
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(
|
fn context_menu(
|
||||||
self,
|
self,
|
||||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||||
) -> Self {
|
) -> ContextMenu<Self>
|
||||||
self.child(ContextMenu::new("context-menu").menu(f))
|
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<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
|
impl<E: ParentElement + Styled> ContextMenuExt for E {}
|
||||||
|
|
||||||
/// A context menu that can be shown on right-click.
|
/// A context menu that can be shown on right-click.
|
||||||
#[allow(clippy::type_complexity)]
|
pub struct ContextMenu<E: ParentElement + Styled + Sized> {
|
||||||
pub struct ContextMenu {
|
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
menu:
|
element: Option<E>,
|
||||||
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
|
#[allow(clippy::type_complexity)]
|
||||||
|
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
|
||||||
|
// This is not in use, just for style refinement forwarding.
|
||||||
|
_ignore_style: StyleRefinement,
|
||||||
anchor: Corner,
|
anchor: Corner,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextMenu {
|
impl<E: ParentElement + Styled> ContextMenu<E> {
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
/// Create a new context menu with the given ID.
|
||||||
|
pub fn new(id: impl Into<ElementId>, element: E) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
|
element: Some(element),
|
||||||
menu: None,
|
menu: None,
|
||||||
anchor: Corner::TopLeft,
|
anchor: Corner::TopLeft,
|
||||||
|
_ignore_style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the context menu using the given builder function.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn menu<F>(mut self, builder: F) -> Self
|
fn menu<F>(mut self, builder: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||||
{
|
{
|
||||||
self.menu = Some(Box::new(builder));
|
self.menu = Some(Rc::new(builder));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +84,25 @@ impl ContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoElement for ContextMenu {
|
impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
|
if let Some(element) = &mut self.element {
|
||||||
|
element.extend(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
if let Some(element) = &mut self.element {
|
||||||
|
element.style()
|
||||||
|
} else {
|
||||||
|
&mut self._ignore_style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
|
||||||
type Element = Self;
|
type Element = Self;
|
||||||
|
|
||||||
fn into_element(self) -> Self::Element {
|
fn into_element(self) -> Self::Element {
|
||||||
@@ -83,14 +118,14 @@ struct ContextMenuSharedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ContextMenuState {
|
pub struct ContextMenuState {
|
||||||
menu_element: Option<AnyElement>,
|
element: Option<AnyElement>,
|
||||||
shared_state: Rc<RefCell<ContextMenuSharedState>>,
|
shared_state: Rc<RefCell<ContextMenuSharedState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ContextMenuState {
|
impl Default for ContextMenuState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
menu_element: None,
|
element: None,
|
||||||
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
|
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
|
||||||
menu_view: None,
|
menu_view: None,
|
||||||
open: false,
|
open: false,
|
||||||
@@ -101,8 +136,8 @@ impl Default for ContextMenuState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Element for ContextMenu {
|
impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
|
||||||
type PrepaintState = ();
|
type PrepaintState = Hitbox;
|
||||||
type RequestLayoutState = ContextMenuState;
|
type RequestLayoutState = ContextMenuState;
|
||||||
|
|
||||||
fn id(&self) -> Option<ElementId> {
|
fn id(&self) -> Option<ElementId> {
|
||||||
@@ -113,7 +148,6 @@ impl Element for ContextMenu {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::field_reassign_with_default)]
|
|
||||||
fn request_layout(
|
fn request_layout(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: Option<&gpui::GlobalElementId>,
|
id: Option<&gpui::GlobalElementId>,
|
||||||
@@ -121,71 +155,73 @@ impl Element for ContextMenu {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
) -> (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;
|
let anchor = self.anchor;
|
||||||
|
|
||||||
self.with_element_state(
|
self.with_element_state(
|
||||||
id.unwrap(),
|
id.unwrap(),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
|_, state: &mut ContextMenuState, window, cx| {
|
|this, state: &mut ContextMenuState, window, cx| {
|
||||||
let (position, open) = {
|
let (position, open) = {
|
||||||
let shared_state = state.shared_state.borrow();
|
let shared_state = state.shared_state.borrow();
|
||||||
(shared_state.position, shared_state.open)
|
(shared_state.position, shared_state.open)
|
||||||
};
|
};
|
||||||
let menu_view = state.shared_state.borrow().menu_view.clone();
|
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
|
let has_menu_item = menu_view
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|menu| !menu.read(cx).is_empty())
|
.map(|menu| !menu.read(cx).is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if has_menu_item {
|
if has_menu_item {
|
||||||
let mut menu_element = deferred(
|
menu_element = Some(
|
||||||
anchored()
|
deferred(
|
||||||
.position(position)
|
anchored().child(
|
||||||
.snap_to_window_with_margin(px(8.))
|
div()
|
||||||
.anchor(anchor)
|
.w(window.bounds().size.width)
|
||||||
.when_some(menu_view, |this, menu| {
|
.h(window.bounds().size.height)
|
||||||
// Focus the menu, so that can be handle the action.
|
.on_scroll_wheel(|_, _, cx| {
|
||||||
if !menu.focus_handle(cx).contains_focused(window, cx) {
|
cx.stop_propagation();
|
||||||
menu.focus_handle(cx).focus(window, cx);
|
})
|
||||||
}
|
.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()))
|
this.child(menu.clone())
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
.with_priority(1)
|
),
|
||||||
.into_any();
|
)
|
||||||
|
.with_priority(1)
|
||||||
let menu_layout_id = menu_element.request_layout(window, cx);
|
.into_any(),
|
||||||
(Some(menu_element), Some(menu_layout_id))
|
);
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
}
|
}
|
||||||
} 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,
|
layout_id,
|
||||||
ContextMenuState {
|
ContextMenuState {
|
||||||
menu_element,
|
element: Some(element),
|
||||||
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -197,33 +233,33 @@ impl Element for ContextMenu {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_: Option<&gpui::GlobalElementId>,
|
_: Option<&gpui::GlobalElementId>,
|
||||||
_: Option<&InspectorElementId>,
|
_: Option<&InspectorElementId>,
|
||||||
_: gpui::Bounds<gpui::Pixels>,
|
bounds: gpui::Bounds<gpui::Pixels>,
|
||||||
request_layout: &mut Self::RequestLayoutState,
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Self::PrepaintState {
|
) -> Self::PrepaintState {
|
||||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
if let Some(element) = &mut request_layout.element {
|
||||||
menu_element.prepaint(window, cx);
|
element.prepaint(window, cx);
|
||||||
}
|
}
|
||||||
|
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint(
|
fn paint(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: Option<&gpui::GlobalElementId>,
|
id: Option<&gpui::GlobalElementId>,
|
||||||
_: Option<&InspectorElementId>,
|
_: Option<&InspectorElementId>,
|
||||||
bounds: gpui::Bounds<gpui::Pixels>,
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
request_layout: &mut Self::RequestLayoutState,
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
_: &mut Self::PrepaintState,
|
hitbox: &mut Self::PrepaintState,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
if let Some(menu_element) = &mut request_layout.menu_element {
|
if let Some(element) = &mut request_layout.element {
|
||||||
menu_element.paint(window, cx);
|
element.paint(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(builder) = self.menu.take() else {
|
// Take the builder before setting up element state to avoid borrow issues
|
||||||
return;
|
let builder = self.menu.clone();
|
||||||
};
|
|
||||||
|
|
||||||
self.with_element_state(
|
self.with_element_state(
|
||||||
id.unwrap(),
|
id.unwrap(),
|
||||||
@@ -232,33 +268,53 @@ impl Element for ContextMenu {
|
|||||||
|_view, state: &mut ContextMenuState, window, _| {
|
|_view, state: &mut ContextMenuState, window, _| {
|
||||||
let shared_state = state.shared_state.clone();
|
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.
|
// 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| {
|
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||||
if phase.bubble()
|
if phase.bubble()
|
||||||
&& event.button == MouseButton::Right
|
&& event.button == MouseButton::Right
|
||||||
&& bounds.contains(&event.position)
|
&& hitbox.is_hovered(window)
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
let mut shared_state = shared_state.borrow_mut();
|
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.position = event.position;
|
||||||
shared_state.open = true;
|
shared_state.open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
// Use defer to build the menu in the next frame, avoiding race conditions
|
||||||
(builder)(menu, window, cx)
|
window.defer(cx, {
|
||||||
});
|
|
||||||
|
|
||||||
let _subscription = window.subscribe(&menu, cx, {
|
|
||||||
let shared_state = shared_state.clone();
|
let shared_state = shared_state.clone();
|
||||||
move |_, _: &DismissEvent, window, _| {
|
let builder = builder.clone();
|
||||||
shared_state.borrow_mut().open = false;
|
move |window, cx| {
|
||||||
window.refresh();
|
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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
142
crates/ui/src/menu/dropdown_menu.rs
Normal file
142
crates/ui/src/menu/dropdown_menu.rs
Normal file
@@ -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>) -> PopupMenu + 'static,
|
||||||
|
) -> DropdownMenuPopover<Self> {
|
||||||
|
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<Corner>,
|
||||||
|
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||||
|
) -> DropdownMenuPopover<Self> {
|
||||||
|
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<T: Selectable + IntoElement + 'static> {
|
||||||
|
id: ElementId,
|
||||||
|
style: StyleRefinement,
|
||||||
|
anchor: Corner,
|
||||||
|
trigger: T,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> DropdownMenuPopover<T>
|
||||||
|
where
|
||||||
|
T: Selectable + IntoElement + 'static,
|
||||||
|
{
|
||||||
|
fn new(
|
||||||
|
id: ElementId,
|
||||||
|
anchor: impl Into<Corner>,
|
||||||
|
trigger: T,
|
||||||
|
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<Corner>) -> 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<Entity<PopupMenu>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RenderOnce for DropdownMenuPopover<T>
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,22 @@ use theme::ActiveTheme;
|
|||||||
use crate::{h_flex, Disableable, StyledExt};
|
use crate::{h_flex, Disableable, StyledExt};
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) struct MenuItemElement {
|
pub(crate) struct MenuItemElement {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
group_name: SharedString,
|
group_name: SharedString,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||||
children: SmallVec<[AnyElement; 2]>,
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MenuItemElement {
|
impl MenuItemElement {
|
||||||
pub fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
|
/// Create a new MenuItem with the given ID and group name.
|
||||||
|
pub(crate) fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
|
||||||
let id: ElementId = id.into();
|
let id: ElementId = id.into();
|
||||||
Self {
|
Self {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -38,17 +40,19 @@ impl MenuItemElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set ListItem as the selected item style.
|
/// 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.selected = selected;
|
||||||
self
|
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.disabled = disabled;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_click(
|
/// Set a handler for when the MenuItem is clicked.
|
||||||
|
pub(crate) fn on_click(
|
||||||
mut self,
|
mut self,
|
||||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -88,7 +92,7 @@ impl RenderOnce for MenuItemElement {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.group(&self.group_name)
|
.group(&self.group_name)
|
||||||
.gap_x_2()
|
.gap_x_1()
|
||||||
.py_1()
|
.py_1()
|
||||||
.px_2()
|
.px_2()
|
||||||
.text_base()
|
.text_base()
|
||||||
@@ -102,12 +106,12 @@ impl RenderOnce for MenuItemElement {
|
|||||||
})
|
})
|
||||||
.when(!self.disabled, |this| {
|
.when(!self.disabled, |this| {
|
||||||
this.group_hover(self.group_name, |this| {
|
this.group_hover(self.group_name, |this| {
|
||||||
this.bg(cx.theme().elevated_surface_background)
|
this.bg(cx.theme().secondary_background)
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().secondary_foreground)
|
||||||
})
|
})
|
||||||
.when(self.selected, |this| {
|
.when(self.selected, |this| {
|
||||||
this.bg(cx.theme().elevated_surface_background)
|
this.bg(cx.theme().secondary_background)
|
||||||
.text_color(cx.theme().text)
|
.text_color(cx.theme().secondary_foreground)
|
||||||
})
|
})
|
||||||
.when_some(self.on_click, |this, on_click| {
|
.when_some(self.on_click, |this, on_click| {
|
||||||
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use gpui::App;
|
use gpui::App;
|
||||||
|
|
||||||
mod app_menu_bar;
|
mod app_menu_bar;
|
||||||
|
mod context_menu;
|
||||||
|
mod dropdown_menu;
|
||||||
mod menu_item;
|
mod menu_item;
|
||||||
|
mod popup_menu;
|
||||||
pub mod context_menu;
|
|
||||||
pub mod popup_menu;
|
|
||||||
|
|
||||||
pub use app_menu_bar::AppMenuBar;
|
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) {
|
pub(crate) fn init(cx: &mut App) {
|
||||||
app_menu_bar::init(cx);
|
app_menu_bar::init(cx);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
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,
|
BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
|
||||||
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled,
|
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled,
|
||||||
Window,
|
Window,
|
||||||
@@ -13,6 +13,7 @@ use theme::ActiveTheme;
|
|||||||
use crate::actions::{Cancel, Confirm};
|
use crate::actions::{Cancel, Confirm};
|
||||||
use crate::animation::cubic_bezier;
|
use crate::animation::cubic_bezier;
|
||||||
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
||||||
|
use crate::scroll::ScrollableElement;
|
||||||
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
|
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
const CONTEXT: &str = "Modal";
|
const CONTEXT: &str = "Modal";
|
||||||
@@ -489,13 +490,13 @@ impl RenderOnce for Modal {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.h_auto()
|
.h_auto()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.relative()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.pr(padding_right)
|
.pr(padding_right)
|
||||||
.pl(padding_left)
|
.pl(padding_left)
|
||||||
.scrollable(Axis::Vertical)
|
.size_full()
|
||||||
|
.overflow_y_scrollbar()
|
||||||
.child(self.content),
|
.child(self.content),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,129 +1,78 @@
|
|||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent,
|
deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId,
|
||||||
DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding,
|
||||||
GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding,
|
MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement,
|
||||||
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
|
Styled, Subscription, Window,
|
||||||
ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{Selectable, StyledExt as _};
|
use crate::actions::Cancel;
|
||||||
|
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _};
|
||||||
|
|
||||||
const CONTEXT: &str = "Popover";
|
const CONTEXT: &str = "Popover";
|
||||||
|
|
||||||
actions!(popover, [Escape]);
|
pub(crate) fn init(cx: &mut App) {
|
||||||
|
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PopoverChild<T> = Rc<dyn Fn(&mut Window, &mut Context<T>) -> AnyElement>;
|
/// A popover element that can be triggered by a button or any other element.
|
||||||
|
#[derive(IntoElement)]
|
||||||
pub struct PopoverContent {
|
pub struct Popover {
|
||||||
focus_handle: FocusHandle,
|
|
||||||
scroll_handle: ScrollHandle,
|
|
||||||
max_width: Option<Pixels>,
|
|
||||||
max_height: Option<Pixels>,
|
|
||||||
scrollable: bool,
|
|
||||||
child: PopoverChild<Self>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PopoverContent {
|
|
||||||
pub fn new<B>(_window: &mut Window, cx: &mut App, content: B) -> Self
|
|
||||||
where
|
|
||||||
B: Fn(&mut Window, &mut Context<Self>) -> 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<DismissEvent> 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<Self>) -> 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<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>;
|
|
||||||
type Content<M> = Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>;
|
|
||||||
|
|
||||||
pub struct Popover<M: ManagedView> {
|
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
anchor: Corner,
|
style: StyleRefinement,
|
||||||
trigger: Trigger,
|
anchor: Anchor,
|
||||||
content: Content<M>,
|
default_open: bool,
|
||||||
|
open: Option<bool>,
|
||||||
|
tracked_focus_handle: Option<FocusHandle>,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
content: Option<
|
||||||
|
Rc<
|
||||||
|
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
|
||||||
|
+ 'static,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
children: Vec<AnyElement>,
|
||||||
/// Style for trigger element.
|
/// Style for trigger element.
|
||||||
/// This is used for hotfix the trigger element style to support w_full.
|
/// This is used for hotfix the trigger element style to support w_full.
|
||||||
trigger_style: Option<StyleRefinement>,
|
trigger_style: Option<StyleRefinement>,
|
||||||
mouse_button: MouseButton,
|
mouse_button: MouseButton,
|
||||||
no_style: bool,
|
appearance: bool,
|
||||||
|
overlay_closable: bool,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M> Popover<M>
|
impl Popover {
|
||||||
where
|
|
||||||
M: ManagedView,
|
|
||||||
{
|
|
||||||
/// Create a new Popover with `view` mode.
|
/// Create a new Popover with `view` mode.
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
anchor: Corner::TopLeft,
|
style: StyleRefinement::default(),
|
||||||
|
anchor: Anchor::TopLeft,
|
||||||
trigger: None,
|
trigger: None,
|
||||||
trigger_style: None,
|
trigger_style: None,
|
||||||
content: None,
|
content: None,
|
||||||
|
tracked_focus_handle: None,
|
||||||
|
children: vec![],
|
||||||
mouse_button: MouseButton::Left,
|
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 {
|
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
|
||||||
self.anchor = anchor;
|
///
|
||||||
|
/// This method is kept for backward compatibility with `Corner` type.
|
||||||
|
/// Internally, it converts `Corner` to `Anchor`.
|
||||||
|
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||||
|
self.anchor = anchor.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,29 +82,75 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the trigger element of the popover.
|
||||||
pub fn trigger<T>(mut self, trigger: T) -> Self
|
pub fn trigger<T>(mut self, trigger: T) -> Self
|
||||||
where
|
where
|
||||||
T: Selectable + IntoElement + 'static,
|
T: Selectable + IntoElement + 'static,
|
||||||
{
|
{
|
||||||
self.trigger = Some(Box::new(|is_open, _, _| {
|
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
|
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<F>(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 {
|
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||||
self.trigger_style = Some(style);
|
self.trigger_style = Some(style);
|
||||||
self
|
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`.
|
/// This callback will called every time on render the popover.
|
||||||
pub fn content<C>(mut self, content: C) -> Self
|
/// So, you should avoid creating new elements or entities in the content closure.
|
||||||
|
pub fn content<F, E>(mut self, content: F) -> Self
|
||||||
where
|
where
|
||||||
C: Fn(&mut Window, &mut App) -> Entity<M> + 'static,
|
E: IntoElement,
|
||||||
|
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> 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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,302 +160,265 @@ where
|
|||||||
///
|
///
|
||||||
/// - The popover will not have a bg, border, shadow, or padding.
|
/// - The popover will not have a bg, border, shadow, or padding.
|
||||||
/// - The click out of the popover will not dismiss it.
|
/// - The click out of the popover will not dismiss it.
|
||||||
pub fn no_style(mut self) -> Self {
|
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||||
self.no_style = true;
|
self.appearance = appearance;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement {
|
/// Bind the focus handle to receive focus when the popover is opened.
|
||||||
let Some(trigger) = self.trigger.take() else {
|
/// If you not set this, a new focus handle will be created for the popover to
|
||||||
return div().into_any_element();
|
///
|
||||||
|
/// 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<Pixels>) -> Point<Pixels> {
|
||||||
|
let offset = if anchor.is_center() {
|
||||||
|
gpui::point(trigger_bounds.size.width.half(), px(0.))
|
||||||
|
} else {
|
||||||
|
Point::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
(trigger)(is_open, window, cx)
|
trigger_bounds.corner(anchor.swap_vertical().into())
|
||||||
}
|
+ offset
|
||||||
|
+ Point {
|
||||||
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
x: px(0.),
|
||||||
bounds.corner(match self.anchor {
|
y: -trigger_bounds.size.height,
|
||||||
Corner::TopLeft => Corner::BottomLeft,
|
}
|
||||||
Corner::TopRight => Corner::BottomRight,
|
|
||||||
Corner::BottomLeft => Corner::TopLeft,
|
|
||||||
Corner::BottomRight => Corner::TopRight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_element_state<R>(
|
|
||||||
&mut self,
|
|
||||||
id: &GlobalElementId,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut Window, &mut App) -> R,
|
|
||||||
) -> R {
|
|
||||||
window.with_optional_element_state::<PopoverElementState<M>, _>(
|
|
||||||
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))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M> IntoElement for Popover<M>
|
impl ParentElement for Popover {
|
||||||
where
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
M: ManagedView,
|
self.children.extend(elements);
|
||||||
{
|
|
||||||
type Element = Self;
|
|
||||||
|
|
||||||
fn into_element(self) -> Self::Element {
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PopoverElementState<M> {
|
impl Styled for Popover {
|
||||||
trigger_layout_id: Option<LayoutId>,
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
popover_layout_id: Option<LayoutId>,
|
&mut self.style
|
||||||
popover_element: Option<AnyElement>,
|
}
|
||||||
trigger_element: Option<AnyElement>,
|
|
||||||
content_view: Rc<RefCell<Option<Entity<M>>>>,
|
|
||||||
/// Trigger bounds for positioning the popover.
|
|
||||||
trigger_bounds: Option<Bounds<Pixels>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M> Default for PopoverElementState<M> {
|
pub struct PopoverState {
|
||||||
fn default() -> Self {
|
focus_handle: FocusHandle,
|
||||||
|
pub(crate) tracked_focus_handle: Option<FocusHandle>,
|
||||||
|
trigger_bounds: Bounds<Pixels>,
|
||||||
|
open: bool,
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||||
|
|
||||||
|
_dismiss_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopoverState {
|
||||||
|
pub fn new(default_open: bool, cx: &mut App) -> Self {
|
||||||
Self {
|
Self {
|
||||||
trigger_layout_id: None,
|
focus_handle: cx.focus_handle(),
|
||||||
popover_layout_id: None,
|
tracked_focus_handle: None,
|
||||||
popover_element: None,
|
trigger_bounds: Bounds::default(),
|
||||||
trigger_element: None,
|
open: default_open,
|
||||||
content_view: Rc::new(RefCell::new(None)),
|
on_open_change: None,
|
||||||
trigger_bounds: None,
|
_dismiss_subscription: None,
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PrepaintState {
|
|
||||||
hitbox: Hitbox,
|
|
||||||
/// Trigger bounds for limit a rect to handle mouse click.
|
|
||||||
trigger_bounds: Option<Bounds<Pixels>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<M: ManagedView> Element for Popover<M> {
|
|
||||||
type PrepaintState = PrepaintState;
|
|
||||||
type RequestLayoutState = PopoverElementState<M>;
|
|
||||||
|
|
||||||
fn id(&self) -> Option<ElementId> {
|
|
||||||
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<gpui::Pixels>,
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint(
|
/// Check if the popover is open.
|
||||||
&mut self,
|
pub fn is_open(&self) -> bool {
|
||||||
id: Option<&GlobalElementId>,
|
self.open
|
||||||
_: Option<&gpui::InspectorElementId>,
|
}
|
||||||
_bounds: Bounds<Pixels>,
|
|
||||||
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;
|
|
||||||
|
|
||||||
if let Some(mut element) = request_layout.trigger_element.take() {
|
/// Dismiss the popover if it is open.
|
||||||
element.paint(window, cx);
|
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
}
|
if self.open {
|
||||||
|
self.toggle_open(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(mut element) = request_layout.popover_element.take() {
|
/// Open the popover if it is closed.
|
||||||
element.paint(window, cx);
|
pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
return;
|
if !self.open {
|
||||||
}
|
self.toggle_open(window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When mouse click down in the trigger bounds, open the popover.
|
fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(content_build) = this.content.take() else {
|
self.open = !self.open;
|
||||||
return;
|
if self.open {
|
||||||
};
|
let state = cx.entity();
|
||||||
let old_content_view = element_state.content_view.clone();
|
let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
|
||||||
let hitbox_id = prepaint.hitbox.id;
|
{
|
||||||
let mouse_button = this.mouse_button;
|
tracked_focus_handle
|
||||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
} else {
|
||||||
if phase == DispatchPhase::Bubble
|
self.focus_handle.clone()
|
||||||
&& event.button == mouse_button
|
};
|
||||||
&& hitbox_id.is_hovered(window)
|
focus_handle.focus(window, cx);
|
||||||
{
|
|
||||||
cx.stop_propagation();
|
|
||||||
window.prevent_default();
|
|
||||||
|
|
||||||
let new_content_view = (content_build)(window, cx);
|
self._dismiss_subscription =
|
||||||
let old_content_view1 = old_content_view.clone();
|
Some(
|
||||||
|
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
|
||||||
let previous_focus_handle = window.focused(cx);
|
state.update(cx, |state, cx| {
|
||||||
|
state.dismiss(window, 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);
|
|
||||||
window.refresh();
|
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>) {
|
||||||
|
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<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<DismissEvent> for PopoverState {}
|
||||||
|
|
||||||
|
impl Popover {
|
||||||
|
pub(crate) fn render_popover<E>(
|
||||||
|
anchor: Anchor,
|
||||||
|
trigger_bounds: Bounds<Pixels>,
|
||||||
|
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<Div> {
|
||||||
|
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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,232 +1,209 @@
|
|||||||
|
use std::panic::Location;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId,
|
div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||||
InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement,
|
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||||
Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement,
|
|
||||||
Style, 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.
|
/// A trait for elements that can be made scrollable with scrollbars.
|
||||||
pub struct Scrollable<E> {
|
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
|
||||||
|
/// Adds a scrollbar to the element.
|
||||||
|
#[track_caller]
|
||||||
|
fn scrollbar<H: ScrollbarHandle + Clone>(
|
||||||
|
self,
|
||||||
|
scroll_handle: &H,
|
||||||
|
axis: impl Into<ScrollbarAxis>,
|
||||||
|
) -> 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<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
|
||||||
|
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
|
||||||
|
}
|
||||||
|
/// Adds a horizontal scrollbar to the element.
|
||||||
|
#[track_caller]
|
||||||
|
fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(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<Self> {
|
||||||
|
Scrollable::new(self, ScrollbarAxis::Both)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar.
|
||||||
|
#[track_caller]
|
||||||
|
fn overflow_x_scrollbar(self) -> Scrollable<Self> {
|
||||||
|
Scrollable::new(self, ScrollbarAxis::Horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
|
||||||
|
#[track_caller]
|
||||||
|
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
|
||||||
|
Scrollable::new(self, ScrollbarAxis::Vertical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scrollable element wrapper that adds scrollbars to an interactive element.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
element: Option<E>,
|
element: E,
|
||||||
axis: ScrollbarAxis,
|
axis: ScrollbarAxis,
|
||||||
/// This is a fake element to handle Styled, InteractiveElement, not used.
|
|
||||||
_element: Stateful<Div>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> Scrollable<E>
|
impl<E> Scrollable<E>
|
||||||
where
|
where
|
||||||
E: Element,
|
E: InteractiveElement + Styled + ParentElement + Element,
|
||||||
{
|
{
|
||||||
pub(crate) fn new(axis: impl Into<ScrollbarAxis>, element: E) -> Self {
|
#[track_caller]
|
||||||
let id = ElementId::Name(SharedString::from(
|
fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
|
||||||
format!("scrollable-{:?}", element.id(),),
|
let caller = Location::caller();
|
||||||
));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
element: Some(element),
|
id: ElementId::CodeLocation(*caller),
|
||||||
_element: div().id("fake"),
|
element,
|
||||||
id,
|
|
||||||
axis: axis.into(),
|
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<ScrollbarAxis>) {
|
|
||||||
self.axis = axis.into();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_element_state<R>(
|
|
||||||
&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::<ScrollViewState, _>(
|
|
||||||
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<E> ParentElement for Scrollable<E>
|
|
||||||
where
|
|
||||||
E: Element + ParentElement,
|
|
||||||
{
|
|
||||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
|
||||||
if let Some(element) = &mut self.element {
|
|
||||||
element.extend(elements);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> Styled for Scrollable<E>
|
impl<E> Styled for Scrollable<E>
|
||||||
where
|
where
|
||||||
E: Element + Styled,
|
E: InteractiveElement + Styled + ParentElement + Element,
|
||||||
{
|
{
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
if let Some(element) = &mut self.element {
|
self.element.style()
|
||||||
element.style()
|
|
||||||
} else {
|
|
||||||
self._element.style()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> InteractiveElement for Scrollable<E>
|
impl<E> ParentElement for Scrollable<E>
|
||||||
where
|
where
|
||||||
E: Element + InteractiveElement,
|
E: InteractiveElement + Styled + ParentElement + Element,
|
||||||
{
|
{
|
||||||
fn interactivity(&mut self) -> &mut Interactivity {
|
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||||
if let Some(element) = &mut self.element {
|
self.element.extend(elements)
|
||||||
element.interactivity()
|
|
||||||
} else {
|
|
||||||
self._element.interactivity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
|
|
||||||
|
|
||||||
impl<E> IntoElement for Scrollable<E>
|
|
||||||
where
|
|
||||||
E: Element,
|
|
||||||
{
|
|
||||||
type Element = Self;
|
|
||||||
|
|
||||||
fn into_element(self) -> Self::Element {
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> Element for Scrollable<E>
|
impl InteractiveElement for Scrollable<Div> {
|
||||||
|
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||||
|
self.element.interactivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractiveElement for Scrollable<Stateful<Div>> {
|
||||||
|
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||||
|
self.element.interactivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> RenderOnce for Scrollable<E>
|
||||||
where
|
where
|
||||||
E: Element,
|
E: InteractiveElement + Styled + ParentElement + Element + 'static,
|
||||||
{
|
{
|
||||||
type PrepaintState = ScrollViewState;
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
type RequestLayoutState = AnyElement;
|
let scroll_handle = window
|
||||||
|
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
|
||||||
|
.read(cx)
|
||||||
|
.clone();
|
||||||
|
|
||||||
fn id(&self) -> Option<ElementId> {
|
// Inherit the size from the element style.
|
||||||
Some(self.id.clone())
|
let style = StyleRefinement {
|
||||||
}
|
size: self.element.style().size.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(),
|
|
||||||
},
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let axis = self.axis;
|
div()
|
||||||
let scroll_id = self.id.clone();
|
.id(self.id)
|
||||||
let content = self.element.take().map(|c| c.into_any_element());
|
.size_full()
|
||||||
|
.refine_style(&style)
|
||||||
self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| {
|
.relative()
|
||||||
let mut element = div()
|
.child(
|
||||||
.relative()
|
div()
|
||||||
.size_full()
|
.id("scroll-area")
|
||||||
.overflow_hidden()
|
.flex()
|
||||||
.child(
|
.size_full()
|
||||||
div()
|
.track_scroll(&scroll_handle)
|
||||||
.id(scroll_id)
|
.map(|this| match self.axis {
|
||||||
.track_scroll(&element_state.handle)
|
ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
|
||||||
.overflow_scroll()
|
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
|
||||||
.relative()
|
ScrollbarAxis::Both => this.overflow_scroll(),
|
||||||
.size_full()
|
})
|
||||||
.child(div().children(content)),
|
.child(
|
||||||
)
|
self.element
|
||||||
.child(
|
// Refine element size to `flex_1`.
|
||||||
div()
|
.size_auto()
|
||||||
.absolute()
|
.flex_1(),
|
||||||
.top_0()
|
),
|
||||||
.left_0()
|
)
|
||||||
.right_0()
|
.child(render_scrollbar(
|
||||||
.bottom_0()
|
"scrollbar",
|
||||||
.child(
|
&scroll_handle,
|
||||||
Scrollbar::both(&element_state.state, &element_state.handle).axis(axis),
|
self.axis,
|
||||||
),
|
window,
|
||||||
)
|
cx,
|
||||||
.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<Pixels>,
|
|
||||||
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<Pixels>,
|
|
||||||
element: &mut Self::RequestLayoutState,
|
|
||||||
_: &mut Self::PrepaintState,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
element.paint(window, cx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ScrollableElement for Div {}
|
||||||
|
impl<E> ScrollableElement for Stateful<E>
|
||||||
|
where
|
||||||
|
E: ParentElement + Styled + Element,
|
||||||
|
Self: InteractiveElement,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
|
||||||
|
id: ElementId,
|
||||||
|
axis: ScrollbarAxis,
|
||||||
|
scroll_handle: Rc<H>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H> RenderOnce for ScrollbarLayer<H>
|
||||||
|
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<H: ScrollbarHandle + Clone>(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
use std::panic::Location;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
|
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
|
||||||
CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId,
|
CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
|
||||||
IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
|
||||||
Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window,
|
MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
|
||||||
|
UniformListScrollHandle, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::{ActiveTheme, ScrollbarMode};
|
||||||
|
|
||||||
use crate::AxisExt;
|
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 MIN_THUMB_SIZE: f32 = 48.;
|
||||||
|
|
||||||
const THUMB_WIDTH: Pixels = px(6.);
|
const THUMB_WIDTH: Pixels = px(6.);
|
||||||
const THUMB_RADIUS: Pixels = px(6. / 2.);
|
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_WIDTH: Pixels = px(8.);
|
||||||
const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
|
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_DURATION: f32 = 3.0;
|
||||||
const FADE_OUT_DELAY: f32 = 2.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<Pixels>;
|
fn offset(&self) -> Point<Pixels>;
|
||||||
|
/// Set the offset of the scroll handle.
|
||||||
fn set_offset(&self, offset: Point<Pixels>);
|
fn set_offset(&self, offset: Point<Pixels>);
|
||||||
fn is_uniform_list(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
/// The full size of the content, including padding.
|
/// The full size of the content, including padding.
|
||||||
fn content_size(&self) -> Size<Pixels>;
|
fn content_size(&self) -> Size<Pixels>;
|
||||||
|
/// 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<Pixels> {
|
fn offset(&self) -> Point<Pixels> {
|
||||||
self.offset()
|
self.offset()
|
||||||
}
|
}
|
||||||
@@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollHandleOffsetable for UniformListScrollHandle {
|
impl ScrollbarHandle for UniformListScrollHandle {
|
||||||
fn offset(&self) -> Point<Pixels> {
|
fn offset(&self) -> Point<Pixels> {
|
||||||
self.0.borrow().base_handle.offset()
|
self.0.borrow().base_handle.offset()
|
||||||
}
|
}
|
||||||
@@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle {
|
|||||||
self.0.borrow_mut().base_handle.set_offset(offset)
|
self.0.borrow_mut().base_handle.set_offset(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_uniform_list(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content_size(&self) -> Size<Pixels> {
|
fn content_size(&self) -> Size<Pixels> {
|
||||||
let base_handle = &self.0.borrow().base_handle;
|
let base_handle = &self.0.borrow().base_handle;
|
||||||
base_handle.max_offset() + base_handle.bounds().size
|
base_handle.max_offset() + base_handle.bounds().size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
impl ScrollbarHandle for ListState {
|
||||||
pub struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
|
fn offset(&self) -> Point<Pixels> {
|
||||||
|
self.scroll_px_offset_for_scrollbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_offset(&self, offset: Point<Pixels>) {
|
||||||
|
self.set_offset_from_scrollbar(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_size(&self) -> Size<Pixels> {
|
||||||
|
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<Cell<ScrollbarStateInner>>);
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct ScrollbarStateInner {
|
struct ScrollbarStateInner {
|
||||||
hovered_axis: Option<Axis>,
|
hovered_axis: Option<Axis>,
|
||||||
hovered_on_thumb: Option<Axis>,
|
hovered_on_thumb: Option<Axis>,
|
||||||
dragged_axis: Option<Axis>,
|
dragged_axis: Option<Axis>,
|
||||||
@@ -83,6 +110,7 @@ pub struct ScrollbarStateInner {
|
|||||||
last_scroll_time: Option<Instant>,
|
last_scroll_time: Option<Instant>,
|
||||||
// Last update offset
|
// Last update offset
|
||||||
last_update: Instant,
|
last_update: Instant,
|
||||||
|
idle_timer_scheduled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScrollbarState {
|
impl Default for ScrollbarState {
|
||||||
@@ -95,6 +123,7 @@ impl Default for ScrollbarState {
|
|||||||
last_scroll_offset: point(px(0.), px(0.)),
|
last_scroll_offset: point(px(0.), px(0.)),
|
||||||
last_scroll_time: None,
|
last_scroll_time: None,
|
||||||
last_update: Instant::now(),
|
last_update: Instant::now(),
|
||||||
|
idle_timer_scheduled: false,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +196,12 @@ impl ScrollbarStateInner {
|
|||||||
state
|
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 {
|
fn is_scrollbar_visible(&self) -> bool {
|
||||||
// On drag
|
// On drag
|
||||||
if self.dragged_axis.is_some() {
|
if self.dragged_axis.is_some() {
|
||||||
@@ -182,10 +217,14 @@ impl ScrollbarStateInner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scrollbar axis.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ScrollbarAxis {
|
pub enum ScrollbarAxis {
|
||||||
|
/// Vertical scrollbar.
|
||||||
Vertical,
|
Vertical,
|
||||||
|
/// Horizontal scrollbar.
|
||||||
Horizontal,
|
Horizontal,
|
||||||
|
/// Show both vertical and horizontal scrollbars.
|
||||||
Both,
|
Both,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,25 +239,30 @@ impl From<Axis> for ScrollbarAxis {
|
|||||||
|
|
||||||
impl ScrollbarAxis {
|
impl ScrollbarAxis {
|
||||||
/// Return true if the scrollbar axis is vertical.
|
/// Return true if the scrollbar axis is vertical.
|
||||||
|
#[inline]
|
||||||
pub fn is_vertical(&self) -> bool {
|
pub fn is_vertical(&self) -> bool {
|
||||||
matches!(self, Self::Vertical)
|
matches!(self, Self::Vertical)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if the scrollbar axis is horizontal.
|
/// Return true if the scrollbar axis is horizontal.
|
||||||
|
#[inline]
|
||||||
pub fn is_horizontal(&self) -> bool {
|
pub fn is_horizontal(&self) -> bool {
|
||||||
matches!(self, Self::Horizontal)
|
matches!(self, Self::Horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if the scrollbar axis is both vertical and horizontal.
|
/// Return true if the scrollbar axis is both vertical and horizontal.
|
||||||
|
#[inline]
|
||||||
pub fn is_both(&self) -> bool {
|
pub fn is_both(&self) -> bool {
|
||||||
matches!(self, Self::Both)
|
matches!(self, Self::Both)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return true if the scrollbar has vertical axis.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn has_vertical(&self) -> bool {
|
pub fn has_vertical(&self) -> bool {
|
||||||
matches!(self, Self::Vertical | Self::Both)
|
matches!(self, Self::Vertical | Self::Both)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return true if the scrollbar has horizontal axis.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn has_horizontal(&self) -> bool {
|
pub fn has_horizontal(&self) -> bool {
|
||||||
matches!(self, Self::Horizontal | Self::Both)
|
matches!(self, Self::Horizontal | Self::Both)
|
||||||
@@ -238,9 +282,10 @@ impl ScrollbarAxis {
|
|||||||
|
|
||||||
/// Scrollbar control for scroll-area or a uniform-list.
|
/// Scrollbar control for scroll-area or a uniform-list.
|
||||||
pub struct Scrollbar {
|
pub struct Scrollbar {
|
||||||
|
pub(crate) id: ElementId,
|
||||||
axis: ScrollbarAxis,
|
axis: ScrollbarAxis,
|
||||||
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
|
scrollbar_mode: Option<ScrollbarMode>,
|
||||||
state: ScrollbarState,
|
scroll_handle: Rc<dyn ScrollbarHandle>,
|
||||||
scroll_size: Option<Size<Pixels>>,
|
scroll_size: Option<Size<Pixels>>,
|
||||||
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
|
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
|
||||||
///
|
///
|
||||||
@@ -250,50 +295,46 @@ pub struct Scrollbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Scrollbar {
|
impl Scrollbar {
|
||||||
fn new(
|
/// Create a new scrollbar.
|
||||||
axis: impl Into<ScrollbarAxis>,
|
///
|
||||||
state: &ScrollbarState,
|
/// This will have both vertical and horizontal scrollbars.
|
||||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
#[track_caller]
|
||||||
) -> Self {
|
pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||||
|
let caller = Location::caller();
|
||||||
Self {
|
Self {
|
||||||
state: state.clone(),
|
id: ElementId::CodeLocation(*caller),
|
||||||
axis: axis.into(),
|
axis: ScrollbarAxis::Both,
|
||||||
scroll_handle: Rc::new(Box::new(scroll_handle.clone())),
|
scrollbar_mode: None,
|
||||||
|
scroll_handle: Rc::new(scroll_handle.clone()),
|
||||||
max_fps: 120,
|
max_fps: 120,
|
||||||
scroll_size: None,
|
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.
|
/// Create with horizontal scrollbar.
|
||||||
pub fn horizontal(
|
#[track_caller]
|
||||||
state: &ScrollbarState,
|
pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
|
||||||
) -> Self {
|
|
||||||
Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create with vertical scrollbar.
|
/// Create with vertical scrollbar.
|
||||||
pub fn vertical(
|
#[track_caller]
|
||||||
state: &ScrollbarState,
|
pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
|
||||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
|
||||||
) -> Self {
|
|
||||||
Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create vertical scrollbar for uniform list.
|
/// Set a specific element id, default is the [`Location::caller`].
|
||||||
pub fn uniform_scroll(
|
///
|
||||||
state: &ScrollbarState,
|
/// NOTE: In most cases, you don't need to set a specific id for scrollbar.
|
||||||
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static),
|
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
|
||||||
) -> Self {
|
self.id = id.into();
|
||||||
Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
|
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.
|
/// 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.
|
/// If you have very high CPU usage, consider reducing this value to improve performance.
|
||||||
///
|
///
|
||||||
/// Available values: 30..120
|
/// 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.max_fps = max_fps.clamp(30, 120);
|
||||||
self
|
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) {
|
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||||
(
|
(
|
||||||
cx.theme().scrollbar_thumb_hover_background,
|
cx.theme().scrollbar_thumb_hover_background,
|
||||||
@@ -353,11 +401,28 @@ impl Scrollbar {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
|
||||||
let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() {
|
let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
|
||||||
(THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS)
|
let (width, inset, radius) = match scrollbar_mode {
|
||||||
} else {
|
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
|
||||||
(THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_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 {
|
pub struct PrepaintState {
|
||||||
hitbox: Hitbox,
|
hitbox: Hitbox,
|
||||||
|
scrollbar_state: ScrollbarState,
|
||||||
states: Vec<AxisPrepaintState>,
|
states: Vec<AxisPrepaintState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
pub struct AxisPrepaintState {
|
pub struct AxisPrepaintState {
|
||||||
axis: Axis,
|
axis: Axis,
|
||||||
bar_hitbox: Hitbox,
|
bar_hitbox: Hitbox,
|
||||||
@@ -406,7 +474,7 @@ impl Element for Scrollbar {
|
|||||||
type RequestLayoutState = ();
|
type RequestLayoutState = ();
|
||||||
|
|
||||||
fn id(&self) -> Option<gpui::ElementId> {
|
fn id(&self) -> Option<gpui::ElementId> {
|
||||||
None
|
Some(self.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||||
@@ -420,11 +488,11 @@ impl Element for Scrollbar {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> (LayoutId, Self::RequestLayoutState) {
|
) -> (LayoutId, Self::RequestLayoutState) {
|
||||||
let style = gpui::Style {
|
let style = Style {
|
||||||
position: Position::Absolute,
|
position: Position::Absolute,
|
||||||
flex_grow: 1.0,
|
flex_grow: 1.0,
|
||||||
flex_shrink: 1.0,
|
flex_shrink: 1.0,
|
||||||
size: gpui::Size {
|
size: Size {
|
||||||
width: relative(1.).into(),
|
width: relative(1.).into(),
|
||||||
height: relative(1.).into(),
|
height: relative(1.).into(),
|
||||||
},
|
},
|
||||||
@@ -447,6 +515,11 @@ impl Element for Scrollbar {
|
|||||||
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let state = window
|
||||||
|
.use_state(cx, |_, _| ScrollbarState::default())
|
||||||
|
.read(cx)
|
||||||
|
.clone();
|
||||||
|
|
||||||
let mut states = vec![];
|
let mut states = vec![];
|
||||||
let mut has_both = self.axis.is_both();
|
let mut has_both = self.axis.is_both();
|
||||||
let scroll_size = self
|
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.
|
// 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 {
|
let margin_end = if has_both && !is_vertical {
|
||||||
THUMB_ACTIVE_WIDTH
|
WIDTH
|
||||||
} else {
|
} else {
|
||||||
px(0.)
|
px(0.)
|
||||||
};
|
};
|
||||||
@@ -512,11 +584,12 @@ impl Element for Scrollbar {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = self.state.clone();
|
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
|
||||||
let is_always_to_show = cx.theme().scrollbar_mode.is_always();
|
let is_always_to_show = scrollbar_show.is_always();
|
||||||
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover();
|
let is_hover_to_show = scrollbar_show.is_hover();
|
||||||
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
|
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_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) =
|
let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
|
||||||
if state.get().dragged_axis == Some(axis) {
|
if state.get().dragged_axis == Some(axis) {
|
||||||
@@ -527,38 +600,47 @@ impl Element for Scrollbar {
|
|||||||
} else {
|
} else {
|
||||||
Self::style_for_hovered_bar(cx)
|
Self::style_for_hovered_bar(cx)
|
||||||
}
|
}
|
||||||
|
} else if is_offset_changed {
|
||||||
|
self.style_for_normal(cx)
|
||||||
} else if is_always_to_show {
|
} else if is_always_to_show {
|
||||||
#[allow(clippy::if_same_then_else)]
|
|
||||||
if is_hovered_on_thumb {
|
if is_hovered_on_thumb {
|
||||||
Self::style_for_hovered_thumb(cx)
|
Self::style_for_hovered_thumb(cx)
|
||||||
} else {
|
} else {
|
||||||
Self::style_for_hovered_bar(cx)
|
Self::style_for_hovered_bar(cx)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
// Delay 2s to fade out the scrollbar thumb (in 1s)
|
||||||
if let Some(last_time) = state.get().last_scroll_time {
|
if let Some(last_time) = state.get().last_scroll_time {
|
||||||
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
||||||
if elapsed < FADE_OUT_DURATION {
|
if is_hovered_on_bar {
|
||||||
if is_hovered_on_bar {
|
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
|
||||||
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
|
idle_state = if is_hovered_on_thumb {
|
||||||
idle_state = if is_hovered_on_thumb {
|
Self::style_for_hovered_thumb(cx)
|
||||||
Self::style_for_hovered_thumb(cx)
|
|
||||||
} else {
|
|
||||||
Self::style_for_hovered_bar(cx)
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
if elapsed < FADE_OUT_DELAY {
|
Self::style_for_hovered_bar(cx)
|
||||||
idle_state.0 = cx.theme().scrollbar_thumb_background;
|
};
|
||||||
} else {
|
} else if elapsed < FADE_OUT_DELAY {
|
||||||
// opacity = 1 - (x - 2)^10
|
idle_state.0 = cx.theme().scrollbar_thumb_background;
|
||||||
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
|
|
||||||
idle_state.0 =
|
|
||||||
cx.theme().scrollbar_thumb_background.opacity(opacity);
|
|
||||||
};
|
|
||||||
|
|
||||||
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(
|
fn paint(
|
||||||
@@ -630,19 +716,21 @@ impl Element for Scrollbar {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
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 view_id = window.current_view();
|
||||||
let hitbox_bounds = prepaint.hitbox.bounds;
|
let hitbox_bounds = prepaint.hitbox.bounds;
|
||||||
let is_visible =
|
let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
|
||||||
self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always();
|
let is_hover_to_show = scrollbar_show.is_hover();
|
||||||
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover();
|
|
||||||
|
|
||||||
// Update last_scroll_time when offset is changed.
|
// Update last_scroll_time when offset is changed.
|
||||||
if self.scroll_handle.offset() != self.state.get().last_scroll_offset {
|
if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
|
||||||
self.state.set(
|
scrollbar_state.set(
|
||||||
self.state
|
scrollbar_state
|
||||||
.get()
|
.get()
|
||||||
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
|
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
|
||||||
);
|
);
|
||||||
|
cx.notify(view_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.with_content_mask(
|
window.with_content_mask(
|
||||||
@@ -652,7 +740,10 @@ impl Element for Scrollbar {
|
|||||||
|window| {
|
|window| {
|
||||||
for state in prepaint.states.iter() {
|
for state in prepaint.states.iter() {
|
||||||
let axis = state.axis;
|
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 bounds = state.bounds;
|
||||||
let thumb_bounds = state.thumb_bounds;
|
let thumb_bounds = state.thumb_bounds;
|
||||||
let scroll_area_size = state.scroll_size;
|
let scroll_area_size = state.scroll_size;
|
||||||
@@ -686,7 +777,7 @@ impl Element for Scrollbar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let state = self.state.clone();
|
let state = scrollbar_state.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
|
|
||||||
move |event: &ScrollWheelEvent, phase, _, cx| {
|
move |event: &ScrollWheelEvent, phase, _, cx| {
|
||||||
@@ -707,7 +798,7 @@ impl Element for Scrollbar {
|
|||||||
|
|
||||||
if is_hover_to_show || is_visible {
|
if is_hover_to_show || is_visible {
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let state = self.state.clone();
|
let state = scrollbar_state.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
|
|
||||||
move |event: &MouseDownEvent, phase, _, cx| {
|
move |event: &MouseDownEvent, phase, _, cx| {
|
||||||
@@ -718,6 +809,7 @@ impl Element for Scrollbar {
|
|||||||
// click on the thumb bar, set the drag position
|
// click on the thumb bar, set the drag position
|
||||||
let pos = event.position - thumb_bounds.origin;
|
let pos = event.position - thumb_bounds.origin;
|
||||||
|
|
||||||
|
scroll_handle.start_drag();
|
||||||
state.set(state.get().with_drag_pos(axis, pos));
|
state.set(state.get().with_drag_pos(axis, pos));
|
||||||
|
|
||||||
cx.notify(view_id);
|
cx.notify(view_id);
|
||||||
@@ -755,7 +847,7 @@ impl Element for Scrollbar {
|
|||||||
|
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
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);
|
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
|
||||||
|
|
||||||
move |event: &MouseMoveEvent, _, _, cx| {
|
move |event: &MouseMoveEvent, _, _, cx| {
|
||||||
@@ -770,9 +862,7 @@ impl Element for Scrollbar {
|
|||||||
if state.get().hovered_axis != Some(axis) {
|
if state.get().hovered_axis != Some(axis) {
|
||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
} else if state.get().hovered_axis == Some(axis)
|
} else if state.get().hovered_axis == Some(axis) {
|
||||||
&& state.get().hovered_axis.is_some()
|
|
||||||
{
|
|
||||||
state.set(state.get().with_hovered(None));
|
state.set(state.get().with_hovered(None));
|
||||||
notify = true;
|
notify = true;
|
||||||
}
|
}
|
||||||
@@ -790,6 +880,9 @@ impl Element for Scrollbar {
|
|||||||
|
|
||||||
// Move thumb position on dragging
|
// Move thumb position on dragging
|
||||||
if state.get().dragged_axis == Some(axis) && event.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
|
// drag_pos is the position of the mouse down event
|
||||||
// We need to keep the thumb bar still at the origin down position
|
// We need to keep the thumb bar still at the origin down position
|
||||||
let drag_pos = state.get().drag_pos;
|
let drag_pos = state.get().drag_pos;
|
||||||
@@ -836,10 +929,12 @@ impl Element for Scrollbar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.on_mouse_event({
|
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| {
|
move |_event: &MouseUpEvent, phase, _, cx| {
|
||||||
if phase.bubble() {
|
if phase.bubble() {
|
||||||
|
scroll_handle.end_drag();
|
||||||
state.set(state.get().with_unset_drag_pos());
|
state.set(state.get().with_unset_drag_pos());
|
||||||
cx.notify(view_id);
|
cx.notify(view_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ impl Skeleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secondary(mut self, secondary: bool) -> Self {
|
pub fn secondary(mut self) -> Self {
|
||||||
self.secondary = secondary;
|
self.secondary = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use std::fmt::{self, Display, Formatter};
|
use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled};
|
||||||
|
|
||||||
use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::scroll::{Scrollable, ScrollbarAxis};
|
|
||||||
|
|
||||||
/// Returns a `Div` as horizontal flex layout.
|
/// Returns a `Div` as horizontal flex layout.
|
||||||
pub fn h_flex() -> Div {
|
pub fn h_flex() -> Div {
|
||||||
div().h_flex()
|
div().h_flex()
|
||||||
@@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized {
|
|||||||
self.flex().flex_col()
|
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<ScrollbarAxis>) -> Scrollable<Self>
|
|
||||||
where
|
|
||||||
Self: Element,
|
|
||||||
{
|
|
||||||
Scrollable::new(axis, self)
|
|
||||||
}
|
|
||||||
|
|
||||||
font_weight!(font_thin, THIN);
|
font_weight!(font_thin, THIN);
|
||||||
font_weight!(font_extralight, EXTRA_LIGHT);
|
font_weight!(font_extralight, EXTRA_LIGHT);
|
||||||
font_weight!(font_light, LIGHT);
|
font_weight!(font_light, LIGHT);
|
||||||
@@ -259,74 +244,6 @@ impl<T: Styled> StyleSized<T> 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.
|
/// A trait for defining element that can be collapsed.
|
||||||
pub trait Collapsible {
|
pub trait Collapsible {
|
||||||
fn collapsed(self, collapsed: bool) -> Self;
|
fn collapsed(self, collapsed: bool) -> Self;
|
||||||
|
|||||||
Reference in New Issue
Block a user