diff --git a/Cargo.lock b/Cargo.lock index b55daa9..794e327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,15 +56,15 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878" +checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" dependencies = [ "aws-lc-sys", "zeroize", @@ -486,9 +486,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.28.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa9b6986f250236c27e5a204062434a773a13243d2ffc2955f37bdba4c5c6a1" +checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" dependencies = [ "bindgen 0.69.5", "cc", @@ -536,7 +536,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -559,7 +559,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -646,9 +646,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -662,13 +662,13 @@ checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" [[package]] name = "blade-graphics" version = "0.6.0" -source = "git+https://github.com/kvark/blade?rev=b16f5c7bd873c7126f48c82c39e7ae64602ae74f#b16f5c7bd873c7126f48c82c39e7ae64602ae74f" +source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" dependencies = [ "ash", "ash-window", - "bitflags 2.9.0", + "bitflags 2.9.1", "bytemuck", - "codespan-reporting", + "codespan-reporting 0.11.1", "glow", "gpu-alloc", "gpu-alloc-ash", @@ -679,9 +679,10 @@ dependencies = [ "log", "mint", "naga", - "objc2 0.5.2", + "objc2", "objc2-app-kit", - "objc2-foundation 0.2.2", + "objc2-core-foundation", + "objc2-foundation", "objc2-metal", "objc2-quartz-core", "objc2-ui-kit", @@ -694,7 +695,7 @@ dependencies = [ [[package]] name = "blade-macros" version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=b16f5c7bd873c7126f48c82c39e7ae64602ae74f#b16f5c7bd873c7126f48c82c39e7ae64602ae74f" +source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" dependencies = [ "proc-macro2", "quote", @@ -704,7 +705,7 @@ dependencies = [ [[package]] name = "blade-util" version = "0.2.0" -source = "git+https://github.com/kvark/blade?rev=b16f5c7bd873c7126f48c82c39e7ae64602ae74f#b16f5c7bd873c7126f48c82c39e7ae64602ae74f" +source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad" dependencies = [ "blade-graphics", "bytemuck", @@ -738,11 +739,11 @@ dependencies = [ [[package]] name = "block2" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ - "objc2 0.5.2", + "objc2", ] [[package]] @@ -824,7 +825,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "log", "polling", "rustix 0.38.44", @@ -873,9 +874,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.21" +version = "1.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" dependencies = [ "jobserver", "libc", @@ -913,12 +914,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1017,9 +1012,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", "clap_derive", @@ -1027,9 +1022,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstream", "anstyle", @@ -1086,7 +1081,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "block", "cocoa-foundation 0.2.0", "core-foundation 0.10.0", @@ -1116,7 +1111,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "block", "core-foundation 0.10.0", "core-graphics-types 0.2.0", @@ -1131,13 +1126,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.0", ] [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1293,7 +1299,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.10.0", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -1306,7 +1312,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -1330,7 +1336,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.10.0", "libc", ] @@ -1341,7 +1347,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "block", "cfg-if", "core-foundation 0.10.0", @@ -1385,11 +1391,11 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.13.2" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e418dd4f5128c3e93eab12246391c54a20c496811131f85754dc8152ee207892" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "fontdb 0.16.2", "log", "rangemap", @@ -1493,9 +1499,9 @@ checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" [[package]] name = "ctrlc" -version = "3.4.6" +version = "3.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" dependencies = [ "nix", "windows-sys 0.59.0", @@ -1529,7 +1535,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "proc-macro2", "quote", @@ -1595,6 +1601,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1752,9 +1768,9 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", "windows-sys 0.59.0", @@ -1906,12 +1922,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-kit" version = "0.14.1" source = "git+https://github.com/zed-industries/font-kit?rev=5474cfad4b719a72ec8ed2cb7327b2b01fd10568#5474cfad4b719a72ec8ed2cb7327b2b01fd10568" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", @@ -1932,18 +1954,18 @@ dependencies = [ [[package]] name = "font-types" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" dependencies = [ "bytemuck", ] [[package]] name = "fontconfig-parser" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fcfcd44ca6e90c921fee9fa665d530b21ef1327a4c1a6c5250ea44b776ada7" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ "roxmltree", ] @@ -2215,9 +2237,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "js-sys", @@ -2302,7 +2324,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "gpu-alloc-types", ] @@ -2323,13 +2345,13 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2394,7 +2416,7 @@ dependencies = [ "slotmap", "smallvec", "smol", - "strum", + "strum 0.27.1", "sum_tree", "taffy", "thiserror 2.0.12", @@ -2408,9 +2430,9 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "windows 0.61.1", - "windows-core 0.61.0", + "windows-core 0.61.1", "windows-numerics", - "windows-registry 0.5.1", + "windows-registry 0.5.2", "workspace-hack", "x11-clipboard", "x11rb", @@ -2421,7 +2443,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "proc-macro2", "quote", @@ -2462,6 +2484,7 @@ checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ "cfg-if", "crunchy", + "num-traits", ] [[package]] @@ -2475,6 +2498,9 @@ name = "hashbrown" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -2494,7 +2520,7 @@ version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "byteorder", "heed-traits", "heed-types", @@ -2645,7 +2671,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "bytes", @@ -2662,7 +2688,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2762,7 +2788,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.0", + "windows-core 0.61.1", ] [[package]] @@ -2776,21 +2802,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -2799,31 +2826,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -2831,67 +2838,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "idna" version = "1.0.3" @@ -2905,9 +2899,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -3116,7 +3110,7 @@ version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "libc", ] @@ -3210,12 +3204,12 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.0", ] [[package]] @@ -3230,7 +3224,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "libc", ] @@ -3257,9 +3251,9 @@ checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lmdb-master-sys" @@ -3307,6 +3301,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lyon" version = "1.0.1" @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -3440,7 +3440,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -3500,23 +3500,27 @@ dependencies = [ [[package]] name = "naga" -version = "23.1.0" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "364f94bc34f61332abebe8cad6f6cd82a5b65cff22c828d05d0968911462ca4f" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.9.0", - "cfg_aliases 0.1.1", - "codespan-reporting", + "bitflags 2.9.1", + "cfg_aliases", + "codespan-reporting 0.12.0", + "half", + "hashbrown 0.15.3", "hexf-parse", "indexmap", "log", + "num-traits", + "once_cell", "rustc-hash 1.1.0", "spirv", - "termcolor", - "thiserror 1.0.69", - "unicode-xid", + "strum 0.26.3", + "thiserror 2.0.12", + "unicode-ident", ] [[package]] @@ -3565,13 +3569,13 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] @@ -3595,7 +3599,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "aes", "base64", @@ -3620,7 +3624,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "async-utility", "nostr", @@ -3632,7 +3636,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "flatbuffers", "lru", @@ -3643,7 +3647,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "async-utility", "heed", @@ -3656,7 +3660,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "async-utility", "async-wsocket", @@ -3672,7 +3676,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.41.0" -source = "git+https://github.com/rust-nostr/nostr#677cb7fb242fd59f861561203cd7b9502e222642" +source = "git+https://github.com/rust-nostr/nostr#23ab667015623ad681b8c4225d6fbbcffd30b930" dependencies = [ "async-utility", "nostr", @@ -3836,22 +3840,6 @@ dependencies = [ "objc_id", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - [[package]] name = "objc2" version = "0.6.1" @@ -3863,78 +3851,26 @@ dependencies = [ [[package]] name = "objc2-app-kit" -version = "0.2.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.0", - "block2", - "libc", - "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation 0.2.2", + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", "objc2-quartz-core", ] [[package]] -name = "objc2-cloud-kit" -version = "0.2.2" +name = "objc2-core-foundation" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.0", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.9.0", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", + "bitflags 2.9.1", + "dispatch2", + "objc2", ] [[package]] @@ -3943,118 +3879,53 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.9.0", - "block2", - "libc", - "objc2 0.5.2", -] - [[package]] name = "objc2-foundation" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.0", - "objc2 0.6.1", -] - -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", ] [[package]] name = "objc2-metal" -version = "0.2.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "objc2", + "objc2-foundation", ] [[package]] name = "objc2-quartz-core" -version = "0.2.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.0", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", "objc2-metal", ] -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-ui-kit" -version = "0.2.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.0", - "block2", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", "objc2-quartz-core", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.9.0", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", ] [[package]] @@ -4114,7 +3985,7 @@ dependencies = [ "endi", "futures-lite 2.6.0", "futures-util", - "getrandom 0.3.2", + "getrandom 0.3.3", "hkdf", "hmac", "md-5", @@ -4154,7 +4025,7 @@ version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4470,13 +4341,22 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.25", + "zerocopy", ] [[package]] @@ -4578,12 +4458,12 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4598,12 +4478,13 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" dependencies = [ "bytes", - "getrandom 0.3.2", + "getrandom 0.3.3", + "lru-slab", "rand 0.9.1", "ring", "rustc-hash 2.1.1", @@ -4622,7 +4503,7 @@ version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", "socket2", @@ -4701,7 +4582,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", ] [[package]] @@ -4800,9 +4681,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.25.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +checksum = "5ce8e2ca6b24313587a03ca61bb74c384e2a815bd90cf2866cfc9f5fb7a11fa0" dependencies = [ "bytemuck", "font-types", @@ -4814,7 +4695,7 @@ version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] @@ -4831,7 +4712,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "derive_refineable", "workspace-hack", @@ -4970,7 +4851,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "bytes", @@ -5031,9 +4912,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rust-embed" -version = "8.7.1" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60e425e204264b144d4c929d126d0de524b40a961686414bab5040f7465c71be" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -5042,9 +4923,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" dependencies = [ "proc-macro2", "quote", @@ -5055,9 +4936,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" dependencies = [ "globset", "sha2", @@ -5097,7 +4978,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5110,7 +4991,7 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -5193,9 +5074,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.2" +version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ "aws-lc-rs", "ring", @@ -5215,7 +5096,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "bytemuck", "libm", "smallvec", @@ -5232,7 +5113,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "bytemuck", "core_maths", "log", @@ -5402,7 +5283,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -5415,7 +5296,7 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.10.0", "core-foundation-sys", "libc", @@ -5441,7 +5322,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "serde", @@ -5623,9 +5504,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.26.6" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" +checksum = "bbe6666ab11018ab91ff7b03f1a3b9fdbecfb610848436fefa5ce50343d3d913" dependencies = [ "bytemuck", "read-fonts", @@ -5703,7 +5584,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] @@ -5733,13 +5614,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" dependencies = [ - "strum_macros", + "strum_macros 0.27.1", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", ] [[package]] @@ -5764,7 +5667,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "arrayvec", "log", @@ -5868,9 +5771,9 @@ dependencies = [ [[package]] name = "swash" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae9a562c7b46107d9c78cd78b75bbe1e991c16734c0aee8ff0ee711fb8b620a" +checksum = "5dce3f0af95643c855cdc449fbaa17d8c2cd08e0b00a49a6babcbe6e71667f3d" dependencies = [ "skrifa", "yazi", @@ -5957,7 +5860,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6024,12 +5927,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.2", + "getrandom 0.3.3", "once_cell", "rustix 1.0.7", "windows-sys 0.59.0", @@ -6162,9 +6065,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -6580,10 +6483,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "universal-hash" @@ -6646,12 +6549,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8-width" version = "0.1.7" @@ -6673,7 +6570,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#d6c7cdd60ff3cbede868b7c73d92ce808f7b9ee3" +source = "git+https://github.com/zed-industries/zed#0079c99c2ccfdac54b879e5efeec10344f3bee03" dependencies = [ "anyhow", "async-fs", @@ -6703,7 +6600,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "serde", "sha1_smol", ] @@ -6949,7 +6846,7 @@ version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "rustix 0.38.44", "wayland-backend", "wayland-scanner", @@ -6972,7 +6869,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6984,7 +6881,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7045,8 +6942,8 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.1", - "objc2-foundation 0.3.1", + "objc2", + "objc2-foundation", "url", "web-sys", ] @@ -7164,7 +7061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ "windows-collections", - "windows-core 0.61.0", + "windows-core 0.61.1", "windows-future", "windows-link", "windows-numerics", @@ -7191,7 +7088,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", + "windows-core 0.61.1", ] [[package]] @@ -7208,25 +7105,26 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", "windows-link", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-result 0.3.3", + "windows-strings 0.4.1", ] [[package]] name = "windows-future" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.0", + "windows-core 0.61.1", "windows-link", + "windows-threading", ] [[package]] @@ -7285,7 +7183,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", + "windows-core 0.61.1", "windows-link", ] @@ -7295,20 +7193,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result 0.3.2", + "windows-result 0.3.3", "windows-strings 0.3.1", "windows-targets 0.53.0", ] [[package]] name = "windows-registry" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e" +checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" dependencies = [ "windows-link", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-result 0.3.3", + "windows-strings 0.4.1", ] [[package]] @@ -7322,9 +7220,9 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" dependencies = [ "windows-link", ] @@ -7340,9 +7238,9 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" dependencies = [ "windows-link", ] @@ -7445,6 +7343,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7659,7 +7566,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] @@ -7668,17 +7575,11 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x11" @@ -7763,7 +7664,7 @@ name = "xim-parser" version = "0.2.1" source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] @@ -7809,9 +7710,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -7821,9 +7722,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", @@ -7833,9 +7734,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.6.0" +version = "5.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2522b82023923eecb0b366da727ec883ace092e7887b61d3da5139f26b44da58" +checksum = "88232b74ba057a0c85472ec1bae8a17569960be17da2d5e5ad30d5efe7ea6719" dependencies = [ "async-broadcast", "async-executor", @@ -7866,9 +7767,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.6.0" +version = "5.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d2e12843c75108c00c618c2e8ef9675b50b6ec095b36dc965f2e5aed463c15" +checksum = "6969c06899233334676e60da1675740539cf034ee472a6c5b5c54e50a0a554c9" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7893,18 +7794,9 @@ dependencies = [ [[package]] name = "zeno" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0de2315dc13d00e5df3cd6b8d2124a6eaec6a2d4b6a1c5f37b7efad17fcc17" - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" @@ -7912,18 +7804,7 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" dependencies = [ - "zerocopy-derive 0.8.25", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "zerocopy-derive", ] [[package]] @@ -7979,10 +7860,21 @@ dependencies = [ ] [[package]] -name = "zerovec" -version = "0.10.4" +name = "zerotrie" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -7991,9 +7883,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", @@ -8026,9 +7918,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.5.1" +version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "557e89d54880377a507c94cd5452f20e35d14325faf9d2958ebeadce0966c1b2" +checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1" dependencies = [ "endi", "enumflags2", @@ -8041,9 +7933,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.5.1" +version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757779842a0d242061d24c28be589ce392e45350dfb9186dfd7a042a2e19870c" +checksum = "75fda702cd42d735ccd48117b1630432219c0e9616bf6cb0f8350844ee4d9580" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/assets/brand/group.png b/assets/brand/group.png new file mode 100644 index 0000000..452e245 Binary files /dev/null and b/assets/brand/group.png differ diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index 18474c7..e22e6a7 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -1,8 +1,9 @@ use std::{ cmp::Reverse, - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, HashMap}, }; +use account::Account; use anyhow::Error; use common::room_hash; use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; @@ -36,19 +37,21 @@ impl Global for GlobalChatRegistry {} /// - Loading room data from the lmdb /// - Handling messages and room creation pub struct ChatRegistry { - /// Collection of all chat rooms - rooms: BTreeSet>, /// Map of user public keys to their profile metadata profiles: Entity>>, + /// Collection of all chat rooms + pub rooms: Vec>, /// Indicates if rooms are currently being loaded - pub loading: bool, + /// + /// Always equal to `true` when the app starts + pub wait_for_eose: bool, /// Subscriptions for observing changes #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 1]>, + subscriptions: SmallVec<[Subscription; 2]>, } impl ChatRegistry { - /// Retrieve the global ChatRegistry instance + /// Retrieve the Global ChatRegistry instance pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -68,28 +71,37 @@ impl ChatRegistry { let profiles = cx.new(|_| BTreeMap::new()); let mut subscriptions = smallvec![]; - // Observe new Room creations to collect profile metadata - subscriptions.push(cx.observe_new::(|this, _, cx| { - let task = this.metadata(cx); + // When the ChatRegistry is created, load all rooms from the local database + subscriptions.push(cx.observe_new::(|this, window, cx| { + if let Some(window) = window { + this.load_rooms(window, cx); + } + })); - cx.spawn(async move |_, cx| { - if let Ok(data) = task.await { - cx.update(|cx| { - for (public_key, metadata) in data.into_iter() { - Self::global(cx).update(cx, |this, cx| { - this.add_profile(public_key, metadata, cx); - }) - } - }) - .ok(); - } - }) - .detach(); + // When any Room is created, load metadata for all members + subscriptions.push(cx.observe_new::(|this, window, cx| { + if let Some(window) = window { + let task = this.load_metadata(cx); + + cx.spawn_in(window, async move |_, cx| { + if let Ok(data) = task.await { + cx.update(|_, cx| { + for (public_key, metadata) in data.into_iter() { + Self::global(cx).update(cx, |this, cx| { + this.add_profile(public_key, metadata, cx); + }) + } + }) + .ok(); + } + }) + .detach(); + } })); Self { - rooms: BTreeSet::new(), - loading: true, + rooms: vec![], + wait_for_eose: true, profiles, subscriptions, } @@ -103,22 +115,13 @@ impl ChatRegistry { .cloned() } - /// Get all rooms grouped by their kind. - pub fn rooms(&self, cx: &App) -> BTreeMap>> { - let mut groups = BTreeMap::new(); - groups.insert(RoomKind::Ongoing, Vec::new()); - groups.insert(RoomKind::Trusted, Vec::new()); - groups.insert(RoomKind::Unknown, Vec::new()); - - for room in self.rooms.iter() { - let kind = room.read(cx).kind; - groups - .entry(kind) - .or_insert_with(Vec::new) - .push(room.to_owned()); - } - - groups + /// Get rooms by its kind. + pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec> { + self.rooms + .iter() + .filter(|room| room.read(cx).kind == kind) + .cloned() + .collect() } /// Get the IDs of all rooms. @@ -149,13 +152,20 @@ impl ChatRegistry { /// 3. Determines each room's type based on message frequency and trust status /// 4. Creates Room entities for each unique room pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context) { + // [event] is the Nostr Event + // [usize] is the total number of messages, used to determine an ongoing conversation + // [bool] is used to determine if the room is trusted type Rooms = Vec<(Event, usize, bool)>; - let task: Task> = cx.background_spawn(async move { - let client = get_client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; + // If the user is not logged in, do nothing + let Some(user) = Account::get_global(cx).profile_ref() else { + return; + }; + let client = get_client(); + let public_key = user.public_key(); + + let task: Task> = cx.background_spawn(async move { // Get messages sent by the user let send = Filter::new() .kind(Kind::PrivateDirectMessage) @@ -179,7 +189,9 @@ impl ChatRegistry { { let hash = room_hash(&event); + // Check if room's author is seen in any contact list let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey); + // If room's author is seen at least once, mark as trusted let is_trust = client.database().count(filter).await? >= 1; room_map @@ -202,35 +214,32 @@ impl ChatRegistry { cx.spawn_in(window, async move |this, cx| { if let Ok(events) = task.await { - cx.update(|_, cx| { - this.update(cx, |this, cx| { - let ids = this.room_ids(cx); - let rooms: Vec> = events - .into_iter() - .filter_map(|(event, count, trusted)| { - let hash = room_hash(&event); - if !ids.iter().any(|this| this == &hash) { - let kind = if count > 2 { - // If frequency count is greater than 2, mark this room as ongoing - RoomKind::Ongoing - } else if trusted { - RoomKind::Trusted - } else { - RoomKind::Unknown - }; - Some(cx.new(|_| Room::new(&event).kind(kind))) + this.update(cx, |this, cx| { + let ids = this.room_ids(cx); + let rooms: Vec> = events + .into_iter() + .filter_map(|(event, count, trusted)| { + let hash = room_hash(&event); + if !ids.iter().any(|this| this == &hash) { + let kind = if count > 2 { + // If frequency count is greater than 2, mark this room as ongoing + RoomKind::Ongoing + } else if trusted { + RoomKind::Trusted } else { - None - } - }) - .collect(); + RoomKind::Unknown + }; + Some(cx.new(|_| Room::new(&event).kind(kind))) + } else { + None + } + }) + .collect(); - this.rooms.extend(rooms); - this.loading = false; + this.rooms.extend(rooms); + this.wait_for_eose = false; - cx.notify(); - }) - .ok(); + cx.notify(); }) .ok(); } @@ -269,10 +278,24 @@ impl ChatRegistry { Profile::new(*public_key, metadata) } - /// Parse a Nostr event into a Room and push it to the registry + /// Push a Room Entity to the global registry + /// + /// Returns the ID of the room + pub fn push_room(&mut self, room: Entity, cx: &mut Context) -> u64 { + let id = room.read(cx).id; + + if !self.rooms.iter().any(|this| this.read(cx) == room.read(cx)) { + self.rooms.insert(0, room); + cx.notify(); + } + + id + } + + /// Parse a Nostr event into a Coop Room and push it to the global registry /// /// Returns the ID of the new room - pub fn push_event( + pub fn event_to_room( &mut self, event: &Event, window: &mut Window, @@ -282,7 +305,7 @@ impl ChatRegistry { let id = room.id; if !self.rooms.iter().any(|this| this.read(cx) == &room) { - self.rooms.insert(cx.new(|_| room)); + self.rooms.insert(0, cx.new(|_| room)); cx.notify(); } else { window.push_notification("Room already exists", cx); @@ -291,38 +314,25 @@ impl ChatRegistry { id } - /// Parse a nostr event into Room and push to the registry - /// - /// Returns the ID of the new room - pub fn push_room(&mut self, room: Entity, cx: &mut Context) -> u64 { - let id = room.read(cx).id; - - if !self.rooms.iter().any(|this| this.read(cx) == room.read(cx)) { - self.rooms.insert(room); - cx.notify(); - } - - id - } - - /// Push a new message to a room + /// Parse a Nostr event into a Coop Message and push it to the belonging room /// /// If the room doesn't exist, it will be created. /// Updates room ordering based on the most recent messages. - pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { + pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { let id = room_hash(&event); if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { room.update(cx, |this, cx| { this.created_at(event.created_at, cx); - + // Emit the new message to the room cx.defer_in(window, |this, window, cx| { this.emit_message(event, window, cx); }); }); + cx.notify(); } else { // Push the new room to the front of the list - self.rooms.insert(cx.new(|_| Room::new(&event))); + self.rooms.insert(0, cx.new(|_| Room::new(&event))); cx.notify(); } } diff --git a/crates/chats/src/message.rs b/crates/chats/src/message.rs index edbdc49..5ce4933 100644 --- a/crates/chats/src/message.rs +++ b/crates/chats/src/message.rs @@ -2,6 +2,8 @@ use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; +use crate::room::SendError; + /// # Message /// /// Represents a message in the application. @@ -18,8 +20,9 @@ pub struct Message { pub id: EventId, pub content: String, pub author: Profile, - pub mentions: Vec, pub created_at: Timestamp, + pub mentions: Vec, + pub errors: Option>, } impl Message { @@ -42,23 +45,10 @@ impl Message { author, created_at, mentions: vec![], + errors: None, } } - /// Adds or replaces mentions in the message - /// - /// # Arguments - /// - /// * `mentions` - New list of mentioned profiles - /// - /// # Returns - /// - /// The same message with updated mentions - pub fn with_mentions(mut self, mentions: impl IntoIterator) -> Self { - self.mentions.extend(mentions); - self - } - /// Formats the message timestamp as a human-readable relative time /// /// # Returns @@ -85,6 +75,34 @@ impl Message { } .into() } + + /// Adds or replaces mentions in the message + /// + /// # Arguments + /// + /// * `mentions` - New list of mentioned profiles + /// + /// # Returns + /// + /// The same message with updated mentions + pub fn with_mentions(mut self, mentions: impl IntoIterator) -> Self { + self.mentions.extend(mentions); + self + } + + /// Adds or replaces errors in the message + /// + /// # Arguments + /// + /// * `errors` - New list of errors + /// + /// # Returns + /// + /// The same message with updated errors + pub fn with_errors(mut self, errors: Vec) -> Self { + self.errors = Some(errors); + self + } } /// # RoomMessage diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index f379c26..5db3eca 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -1,14 +1,13 @@ use std::{cmp::Ordering, sync::Arc}; use account::Account; -use anyhow::Error; +use anyhow::{anyhow, Error}; use chrono::{Local, TimeZone}; use common::{compare, profile::SharedProfile, room_hash}; use global::get_client; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use itertools::Itertools; use nostr_sdk::prelude::*; -use smol::channel::Receiver; use crate::{ constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}, @@ -17,14 +16,12 @@ use crate::{ }; #[derive(Debug, Clone)] -pub struct IncomingEvent { - pub event: RoomMessage, -} +pub struct Incoming(pub Message); -#[derive(Debug)] -pub enum SendStatus { - Sent(EventId), - Failed(Error), +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SendError { + pub profile: Profile, + pub message: String, } #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] @@ -69,7 +66,7 @@ impl PartialEq for Room { } } -impl EventEmitter for Room {} +impl EventEmitter for Room {} impl Room { /// Creates a new Room instance from a Nostr event @@ -87,6 +84,7 @@ impl Room { // Get all pubkeys from the event's tags let mut pubkeys: Vec = event.tags.public_keys().cloned().collect(); + // The author is always put at the end of the vector pubkeys.push(event.pubkey); // Convert pubkeys into members @@ -267,11 +265,13 @@ impl Room { /// An Option containing the avatar: /// - For a direct message: the other person's avatar /// - For a group chat: None - pub fn display_image(&self, cx: &App) -> Option { - if !self.is_group() { - Some(self.first_member(cx).shared_avatar()) + pub fn display_image(&self, cx: &App) -> SharedString { + if let Some(picture) = self.picture.as_ref() { + picture.clone() + } else if !self.is_group() { + self.first_member(cx).shared_avatar() } else { - None + "brand/group.png".into() } } @@ -327,12 +327,12 @@ impl Room { /// /// A Task that resolves to Result)>, Error> #[allow(clippy::type_complexity)] - pub fn metadata( + pub fn load_metadata( &self, cx: &mut Context, ) -> Task)>, Error>> { let client = get_client(); - let public_keys = self.members.clone(); + let public_keys = Arc::clone(&self.members); cx.background_spawn(async move { let mut output = vec![]; @@ -378,76 +378,6 @@ impl Room { }) } - /// Sends a message to all members in the room - /// - /// # Arguments - /// - /// * `content` - The content of the message to send - /// * `cx` - The App context - /// - /// # Returns - /// - /// A Task that resolves to Result, Error> where the - /// strings contain error messages for any failed sends - pub fn send_message(&self, content: String, cx: &App) -> Option> { - let profile = Account::global(cx).read(cx).profile.clone()?; - let public_key = profile.public_key(); - - let subject = self.subject.clone(); - let picture = self.picture.clone(); - let pubkeys = self.members.clone(); - - let (tx, rx) = smol::channel::bounded::(pubkeys.len()); - - cx.background_spawn(async move { - let client = get_client(); - - let mut tags: Vec = pubkeys - .iter() - .filter_map(|pubkey| { - if pubkey != &public_key { - Some(Tag::public_key(*pubkey)) - } else { - None - } - }) - .collect(); - - // Add subject tag if it's present - if let Some(subject) = subject { - tags.push(Tag::from_standardized(TagStandard::Subject( - subject.to_string(), - ))); - } - - // Add picture tag if it's present - if let Some(picture) = picture { - tags.push(Tag::custom(TagKind::custom("picture"), vec![picture])); - } - - for pubkey in pubkeys.iter() { - match client - .send_private_msg(*pubkey, &content, tags.clone()) - .await - { - Ok(output) => { - if let Err(e) = tx.send(SendStatus::Sent(output.val)).await { - log::error!("Failed to send message to {}: {}", pubkey, e); - } - } - Err(e) => { - if let Err(e) = tx.send(SendStatus::Failed(e.into())).await { - log::error!("Failed to send message to {}: {}", pubkey, e); - } - } - } - } - }) - .detach(); - - Some(rx) - } - /// Loads all messages for this room from the database /// /// # Arguments @@ -461,7 +391,6 @@ impl Room { pub fn load_messages(&self, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = Arc::clone(&self.members); - let profiles: Vec = pubkeys .iter() .map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx)) @@ -492,11 +421,11 @@ impl Room { .collect::>(); for event in events.into_iter() { - let mut mentions = vec![]; let id = event.id; let created_at = event.created_at; let content = event.content.clone(); let tokens = parser.parse(&content); + let mut mentions = vec![]; let author = profiles .iter() @@ -545,66 +474,167 @@ impl Room { /// /// # Effects /// - /// Processes the event and emits an IncomingEvent to the UI when complete - pub fn emit_message(&self, event: Event, window: &mut Window, cx: &mut Context) { - let pubkeys = self.members.clone(); - let profiles: Vec = pubkeys - .iter() - .map(|pubkey| ChatRegistry::global(cx).read(cx).profile(pubkey, cx)) - .collect(); + /// Processes the event and emits an Incoming to the UI when complete + pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context) { + let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx); + let mentions = extract_mentions(&event.content, cx); + let message = + Message::new(event.id, event.content, author, event.created_at).with_mentions(mentions); - let task: Task> = cx.background_spawn(async move { - let parser = NostrParser::new(); - let id = event.id; - let created_at = event.created_at; - let content = event.content.clone(); - let tokens = parser.parse(&content); - let mut mentions = vec![]; + cx.emit(Incoming(message)); + } - let author = profiles + /// Creates a temporary message for optimistic updates + /// + /// This constructs an unsigned message with the current user as the author, + /// extracts any mentions from the content, and packages it as a Message struct. + /// The message will have a generated ID but hasn't been published to relays. + /// + /// # Arguments + /// + /// * `content` - The message content text + /// * `cx` - The application context containing user profile information + /// + /// # Returns + /// + /// Returns `Some(Message)` containing the temporary message if the current user's profile is available, + /// or `None` if no account is found. + pub fn create_temp_message(&self, content: &str, cx: &App) -> Option { + let profile = Account::get_global(cx).profile.clone()?; + let public_key = profile.public_key(); + let builder = EventBuilder::private_msg_rumor(public_key, content); + + // Create a unsigned event to convert to Coop Message + let mut event = builder.build(public_key); + event.ensure_id(); + + // Extract all mentions from content + let mentions = extract_mentions(&event.content, cx); + + Some( + Message::new(event.id.unwrap(), event.content, profile, event.created_at) + .with_mentions(mentions), + ) + } + + /// Sends a message to all members in the background task + /// + /// # Arguments + /// + /// * `content` - The content of the message to send + /// * `cx` - The App context + /// + /// # Returns + /// + /// A Task that resolves to Result, Error> where the + /// strings contain error messages for any failed sends + pub fn send_in_background(&self, msg: &str, cx: &App) -> Task, Error>> { + let content = msg.to_owned(); + let subject = self.subject.clone(); + let picture = self.picture.clone(); + let public_keys = Arc::clone(&self.members); + + cx.background_spawn(async move { + let client = get_client(); + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let mut reports = vec![]; + + let mut tags: Vec = public_keys .iter() - .find(|profile| profile.public_key() == event.pubkey) - .cloned() - .unwrap_or_else(|| Profile::new(event.pubkey, Metadata::default())); - - let pubkey_tokens = tokens - .filter_map(|token| match token { - Token::Nostr(nip21) => match nip21 { - Nip21::Pubkey(pubkey) => Some(pubkey), - Nip21::Profile(profile) => Some(profile.public_key), - _ => None, - }, - _ => None, + .filter_map(|pubkey| { + if pubkey != &public_key { + Some(Tag::public_key(*pubkey)) + } else { + None + } }) - .collect::>(); + .collect(); - for pubkey in pubkey_tokens { - mentions.push( - profiles - .iter() - .find(|profile| profile.public_key() == pubkey) - .cloned() - .unwrap_or_else(|| Profile::new(pubkey, Metadata::default())), - ); + // Add subject tag if it's present + if let Some(subject) = subject { + tags.push(Tag::from_standardized(TagStandard::Subject( + subject.to_string(), + ))); } - let message = Message::new(id, content, author, created_at).with_mentions(mentions); - let room_message = RoomMessage::user(message); - - Ok(room_message) - }); - - cx.spawn_in(window, async move |this, cx| { - if let Ok(message) = task.await { - cx.update(|_, cx| { - this.update(cx, |_, cx| { - cx.emit(IncomingEvent { event: message }); - }) - .ok(); - }) - .ok(); + // Add picture tag if it's present + if let Some(picture) = picture { + tags.push(Tag::custom(TagKind::custom("picture"), vec![picture])); } + + let Some((current_user, receivers)) = public_keys.split_last() else { + return Err(anyhow!("Something is wrong. Cannot get receivers list.")); + }; + + for receiver in receivers.iter() { + if let Err(e) = client + .send_private_msg(*receiver, &content, tags.clone()) + .await + { + let metadata = client + .database() + .metadata(*receiver) + .await? + .unwrap_or_default(); + let profile = Profile::new(*receiver, metadata); + let report = SendError { + profile, + message: e.to_string(), + }; + + reports.push(report); + } + } + + // Only send a backup message to current user if there are no issues when sending to others + if reports.is_empty() { + if let Err(e) = client + .send_private_msg(*current_user, &content, tags.clone()) + .await + { + let metadata = client + .database() + .metadata(*current_user) + .await? + .unwrap_or_default(); + let profile = Profile::new(*current_user, metadata); + let report = SendError { + profile, + message: e.to_string(), + }; + reports.push(report); + } + } + + Ok(reports) }) - .detach(); } } + +pub fn extract_mentions(content: &str, cx: &App) -> Vec { + let parser = NostrParser::new(); + let tokens = parser.parse(content); + let mut mentions = vec![]; + + let profiles = ChatRegistry::get_global(cx).profiles.read(cx); + + let pubkey_tokens = tokens + .filter_map(|token| match token { + Token::Nostr(nip21) => match nip21 { + Nip21::Pubkey(pubkey) => Some(pubkey), + Nip21::Profile(profile) => Some(profile.public_key), + _ => None, + }, + _ => None, + }) + .collect::>(); + + for pubkey in pubkey_tokens.into_iter() { + if let Some(metadata) = profiles.get(&pubkey).cloned() { + mentions.push(Profile::new(pubkey, metadata.unwrap_or_default())); + } + } + + mentions +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 14f5358..6979d56 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -5,7 +5,7 @@ use std::{ }; use global::constants::NIP96_SERVER; -use gpui::Image; +use gpui::{Image, ImageFormat}; use itertools::Itertools; use nostr_sdk::prelude::*; use qrcode_generator::QrCodeEcc; @@ -42,13 +42,9 @@ pub fn room_hash(event: &Event) -> u64 { hasher.finish() } -pub fn create_qr(data: &str) -> Result, anyhow::Error> { - let qr = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?; - let img = Arc::new(Image { - format: gpui::ImageFormat::Png, - bytes: qr.clone(), - id: 1, - }); +pub fn string_to_qr(data: &str) -> Result, anyhow::Error> { + let bytes = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?; + let img = Arc::new(Image::from_bytes(ImageFormat::Png, bytes)); Ok(img) } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 4f15790..45be89d 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -20,7 +20,10 @@ use ui::{ use crate::{ lru_cache::cache_provider, - views::{chat, compose, login, new_account, onboarding, profile, relays, sidebar, welcome}, + views::{ + chat::{self, Chat}, + compose, login, new_account, onboarding, profile, relays, sidebar, welcome, + }, }; const IMAGE_CACHE_SIZE: usize = 200; @@ -79,7 +82,7 @@ pub struct ChatSpace { titlebar: bool, dock: Entity, #[allow(unused)] - subscriptions: SmallVec<[Subscription; 1]>, + subscriptions: SmallVec<[Subscription; 2]>, } impl ChatSpace { @@ -109,6 +112,12 @@ impl ChatSpace { }, )); + subscriptions.push(cx.observe_new::(|this, window, cx| { + if let Some(window) = window { + this.load_messages(window, cx); + } + })); + Self { dock, subscriptions, diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 2ab4709..e42ffe8 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -315,28 +315,25 @@ fn main() { let auto_updater = AutoUpdater::global(cx); match signal { + Signal::Eose => { + chats.update(cx, |this, cx| { + this.load_rooms(window, cx); + }); + } Signal::Event(event) => { chats.update(cx, |this, cx| { - this.push_message(event, window, cx) + this.event_to_message(event, window, cx); }); } Signal::Metadata(data) => { chats.update(cx, |this, cx| { - this.add_profile(data.0, data.1, cx) - }); - } - Signal::Eose => { - chats.update(cx, |this, cx| { - // This function maybe called multiple times - // TODO: only handle the last EOSE signal - this.load_rooms(window, cx) + this.add_profile(data.0, data.1, cx); }); } Signal::AppUpdates(event) => { - // TODO: add settings for auto updates auto_updater.update(cx, |this, cx| { this.update(event, cx); - }) + }); } }; }) diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index 5903551..1b23749 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -3,19 +3,20 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::{anyhow, Error}; use async_utility::task::spawn; use chats::{ - message::RoomMessage, - room::{Room, SendStatus}, + message::{Message, RoomMessage}, + room::Room, ChatRegistry, }; use common::{nip96_upload, profile::SharedProfile}; use global::{constants::IMAGE_SERVICE, get_client}; use gpui::{ div, img, impl_internal_actions, list, prelude::FluentBuilder, px, red, relative, svg, white, - AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten, + AnyElement, App, AppContext, Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; +use itertools::Itertools; use nostr_sdk::prelude::*; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; @@ -25,16 +26,15 @@ use ui::{ button::{Button, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, emoji_picker::EmojiPicker, - input::{InputEvent, TextInput}, + input::{InputEvent, InputState, TextInput}, notification::Notification, popup_menu::PopupMenu, text::RichText, - v_flex, ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt, + v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, }; use crate::views::subject; -const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages."; const DESC: &str = "This conversation is private. Only members can see each other's messages."; #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -60,10 +60,10 @@ pub struct Chat { text_data: HashMap, list_state: ListState, // New Message - input: Entity, + input: Entity, // Media Attachment attaches: Entity>>, - is_uploading: bool, + uploading: bool, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 2]>, } @@ -73,9 +73,13 @@ impl Chat { let messages = cx.new(|_| vec![RoomMessage::announcement()]); let attaches = cx.new(|_| None); let input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) + InputState::new(window, cx) .placeholder("Message...") + .multi_line() + .prevent_new_line_on_enter() + .rows(1) + .clean_on_escape() + .max_rows(20) }); cx.new(|cx| { @@ -84,19 +88,38 @@ impl Chat { subscriptions.push(cx.subscribe_in( &input, window, - move |this: &mut Self, _, event, window, cx| { - if let InputEvent::PressEnter = event { - this.send_message(window, cx); + move |this: &mut Self, input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + if input.read(cx).value().trim().is_empty() { + window.push_notification("Cannot send an empty message", cx); + } else { + this.send_message(window, cx); + } } }, )); - subscriptions.push(cx.subscribe_in( - &room, - window, - move |this, _, event, _window, cx| { + subscriptions.push( + cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| { + let created_at = &incoming.0.created_at.to_string()[..5]; + let content = incoming.0.content.as_str(); + let author = incoming.0.author.public_key(); + + // Check if the incoming message is the same as the new message created by optimistic update + if this.messages.read(cx).iter().any(|msg| { + if let RoomMessage::User(m) = msg { + created_at == &m.created_at.to_string()[..5] + && m.content == content + && m.author.public_key() == author + } else { + false + } + }) { + return; + } + let old_len = this.messages.read(cx).len(); - let message = event.event.clone(); + let message = RoomMessage::user(incoming.0.clone()); cx.update_entity(&this.messages, |this, cx| { this.extend(vec![message]); @@ -104,8 +127,8 @@ impl Chat { }); this.list_state.splice(old_len..old_len, 1); - }, - )); + }), + ); // Initialize list state // [item_count] always equal to 1 at the beginning @@ -119,9 +142,9 @@ impl Chat { } }); - let this = Self { + Self { focus_handle: cx.focus_handle(), - is_uploading: false, + uploading: false, id: id.to_string().into(), text_data: HashMap::new(), room, @@ -130,51 +153,18 @@ impl Chat { input, attaches, subscriptions, - }; - - // Verify messaging relays of all members - this.verify_messaging_relays(window, cx); - - // Load all messages from database - this.load_messages(window, cx); - - this - }) - } - - fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context) { - let room = self.room.read(cx); - let task = room.messaging_relays(cx); - - cx.spawn_in(window, async move |this, cx| { - if let Ok(result) = task.await { - this.update(cx, |this, cx| { - result.into_iter().for_each(|item| { - if !item.1 { - let profile = this - .room - .read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx)); - - this.push_system_message( - format!("{} {}", profile.shared_name(), ALERT), - cx, - ); - } - }); - }) - .ok(); } }) - .detach(); } - fn load_messages(&self, window: &mut Window, cx: &mut Context) { + /// Load all messages belonging to this room + pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context) { let room = self.room.read(cx); let task = room.load_messages(cx); cx.spawn_in(window, async move |this, cx| { - if let Ok(events) = task.await { - cx.update(|_, cx| { + match task.await { + Ok(events) => { this.update(cx, |this, cx| { let old_len = this.messages.read(cx).len(); let new_len = events.len(); @@ -191,13 +181,104 @@ impl Chat { cx.notify(); }) .ok(); - }) - .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } } }) .detach(); } + /// Get user input message including all attachments + fn message(&self, cx: &Context) -> String { + let mut content = self.input.read(cx).value().trim().to_string(); + + // Get all attaches and merge its with message + if let Some(attaches) = self.attaches.read(cx).as_ref() { + if !attaches.is_empty() { + content = format!( + "{}\n{}", + content, + attaches + .iter() + .map(|url| url.to_string()) + .collect_vec() + .join("\n") + ) + } + } + + content + } + + fn send_message(&mut self, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_loading(true, cx); + this.set_disabled(true, cx); + }); + + let content = self.message(cx); + let room = self.room.read(cx); + let temp_message = room.create_temp_message(&content, cx); + let send_message = room.send_in_background(&content, cx); + + if let Some(message) = temp_message { + let id = message.id; + // Optimistically update message list + self.push_user_message(message, cx); + + // Reset the input state + self.input.update(cx, |this, cx| { + this.set_loading(false, cx); + this.set_disabled(false, cx); + this.set_value("", window, cx); + }); + + // Continue sending the message in the background + cx.spawn_in(window, async move |this, cx| { + if let Ok(reports) = send_message.await { + if !reports.is_empty() { + this.update(cx, |this, cx| { + this.messages.update(cx, |this, cx| { + if let Some(msg) = this.iter_mut().find(|msg| { + if let RoomMessage::User(m) = msg { + m.id == id + } else { + false + } + }) { + if let RoomMessage::User(this) = msg { + this.errors = Some(reports) + } + cx.notify(); + } + }); + }) + .ok(); + } + } + }) + .detach(); + } + } + + fn push_user_message(&self, message: Message, cx: &mut Context) { + let old_len = self.messages.read(cx).len(); + let message = RoomMessage::user(message); + + cx.update_entity(&self.messages, |this, cx| { + this.extend(vec![message]); + cx.notify(); + }); + + self.list_state.splice(old_len..old_len, 1); + } + + #[allow(dead_code)] fn push_system_message(&self, content: String, cx: &mut Context) { let old_len = self.messages.read(cx).len(); let message = RoomMessage::system(content.into()); @@ -210,87 +291,19 @@ impl Chat { self.list_state.splice(old_len..old_len, 1); } - fn send_message(&mut self, window: &mut Window, cx: &mut Context) { - let mut content = self.input.read(cx).text().to_string(); - - // Get all attaches and merge its with message - if let Some(attaches) = self.attaches.read(cx).as_ref() { - let merged = attaches - .iter() - .map(|url| url.to_string()) - .collect::>() - .join("\n"); - - content = format!("{}\n{}", content, merged) - } - - // Check if content is empty - if content.is_empty() { - window.push_notification("Cannot send an empty message", cx); + fn upload_media(&mut self, window: &mut Window, cx: &mut Context) { + if self.uploading { return; } - // Update input state - self.input.update(cx, |this, cx| { - this.set_loading(true, cx); - this.set_disabled(true, cx); - }); + self.uploading(true, cx); - let room = self.room.read(cx); - let task = room.send_message(content, cx); - - cx.spawn_in(window, async move |this, cx| { - let mut received = false; - - match task { - Some(rx) => { - while let Ok(message) = rx.recv().await { - if let SendStatus::Failed(error) = message { - cx.update(|window, cx| { - window.push_notification( - Notification::error(error.to_string()) - .title("Message Failed to Send"), - cx, - ); - }) - .ok(); - } else if !received { - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.input.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_disabled(false, cx); - this.set_text("", window, cx); - }); - received = true; - }) - .ok(); - }) - .ok(); - } - } - } - None => { - cx.update(|window, cx| { - window.push_notification(Notification::error("User is not logged in"), cx); - }) - .ok(); - } - } - }) - .detach(); - } - - fn upload_media(&mut self, window: &mut Window, cx: &mut Context) { let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: false, }); - // Show loading spinner - self.set_loading(true, cx); - cx.spawn_in(window, async move |this, cx| { match Flatten::flatten(paths.await.map_err(|e| e.into())) { Ok(Some(mut paths)) => { @@ -300,45 +313,51 @@ impl Chat { if let Ok(file_data) = fs::read(path).await { let client = get_client(); - let (tx, rx) = oneshot::channel::(); + let (tx, rx) = oneshot::channel::>(); - // spawn task via async_utility + // Spawn task via async utility instead of GPUI context spawn(async move { - if let Ok(url) = nip96_upload(client, file_data).await { - _ = tx.send(url); - } + let url = match nip96_upload(client, file_data).await { + Ok(url) => Some(url), + Err(e) => { + log::error!("Upload error: {e}"); + None + } + }; + + _ = tx.send(url); }); - if let Ok(url) = rx.await { - cx.update(|_, cx| { - this.update(cx, |this, cx| { - this.set_loading(false, cx); - - this.attaches.update(cx, |this, cx| { - if let Some(model) = this.as_mut() { - model.push(url); - } else { - *this = Some(vec![url]); - } - cx.notify(); - }); - }) - .ok(); + if let Ok(Some(url)) = rx.await { + this.update(cx, |this, cx| { + this.uploading(false, cx); + this.attaches.update(cx, |this, cx| { + if let Some(model) = this.as_mut() { + model.push(url); + } else { + *this = Some(vec![url]); + } + cx.notify(); + }); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.uploading(false, cx); }) .ok(); } } } Ok(None) => { - cx.update(|_, cx| { - this.update(cx, |this, cx| { - this.set_loading(false, cx); - }) - .ok(); + this.update(cx, |this, cx| { + this.uploading(false, cx); }) .ok(); } - Err(_) => {} + Err(e) => { + log::error!("System error: {e}") + } } }) .detach(); @@ -355,8 +374,8 @@ impl Chat { }); } - fn set_loading(&mut self, status: bool, cx: &mut Context) { - self.is_uploading = status; + fn uploading(&mut self, status: bool, cx: &mut Context) { + self.uploading = status; cx.notify(); } @@ -370,7 +389,18 @@ impl Chat { return div().into_element(); }; - let text_data = &mut self.text_data; + match message { + RoomMessage::User(item) => self.render_user_msg(item, window, cx), + RoomMessage::System(content) => self.render_system_msg(content, cx), + RoomMessage::Announcement => self.render_announcement_msg(cx), + } + } + + fn render_user_msg(&mut self, item: &Message, window: &mut Window, cx: &Context) -> Div { + let texts = self + .text_data + .entry(item.id) + .or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions)); div() .group("") @@ -380,83 +410,136 @@ impl Chat { .gap_3() .px_3() .py_2() - .map(|this| match message { - RoomMessage::User(item) => { - let text = text_data - .entry(item.id) - .or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions)); - - this.hover(|this| this.bg(cx.theme().surface_background)) - .text_sm() - .child( - div() - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().border_transparent) - .group_hover("", |this| this.bg(cx.theme().element_active)), - ) - .child(img(item.author.shared_avatar()).size_8().flex_shrink_0()) - .child( - div() - .flex() - .flex_col() - .flex_initial() - .overflow_hidden() - .child( - div() - .flex() - .items_baseline() - .gap_2() - .child( - div().font_semibold().child(item.author.shared_name()), - ) - .child( - div() - .text_color(cx.theme().text_placeholder) - .child(item.ago()), - ), - ) - .child(text.element("body".into(), window, cx)), - ) - } - RoomMessage::System(content) => this - .items_center() - .child( - div() - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().border_transparent) - .group_hover("", |this| this.bg(red())), - ) - .child(img("brand/avatar.png").size_8().flex_shrink_0()) - .text_sm() - .text_color(red()) - .child(content.clone()), - RoomMessage::Announcement => this - .w_full() - .h_32() + .hover(|this| this.bg(cx.theme().surface_background)) + .child( + div() + .absolute() + .left_0() + .top_0() + .w(px(2.)) + .h_full() + .bg(cx.theme().border_transparent) + .group_hover("", |this| this.bg(cx.theme().element_active)), + ) + .child(img(item.author.shared_avatar()).size_8().flex_shrink_0()) + .child( + div() .flex() .flex_col() - .items_center() - .justify_center() - .text_center() - .text_xs() - .text_color(cx.theme().text_placeholder) - .line_height(relative(1.3)) + .flex_initial() + .overflow_hidden() .child( - svg() - .path("brand/coop.svg") - .size_10() - .text_color(cx.theme().elevated_surface_background), + div() + .flex() + .items_baseline() + .gap_2() + .text_sm() + .child( + div() + .font_semibold() + .text_color(cx.theme().text) + .child(item.author.shared_name()), + ) + .child( + div() + .text_color(cx.theme().text_placeholder) + .child(item.ago()), + ), ) - .child(DESC), - }) + .child(texts.element("body".into(), window, cx)) + .when_some(item.errors.clone(), |this, errors| { + this.child( + div() + .id("") + .flex() + .items_center() + .gap_1() + .text_color(gpui::red()) + .text_xs() + .italic() + .child(Icon::new(IconName::Info).small()) + .child("Failed to send message. Click to see details.") + .on_click(move |_, window, cx| { + let errors = errors.clone(); + + window.open_modal(cx, move |this, _window, cx| { + this.title("Error Logs").child( + div().flex().flex_col().gap_2().px_3().pb_3().children( + errors.clone().into_iter().map(|error| { + div() + .text_sm() + .child( + div() + .flex() + .items_baseline() + .gap_1() + .text_color(cx.theme().text_muted) + .child("Send to:") + .child(error.profile.shared_name()), + ) + .child(error.message) + }), + ), + ) + }); + }), + ) + }), + ) + } + + fn render_system_msg(&mut self, content: &SharedString, cx: &Context) -> Div { + div() + .group("") + .w_full() + .relative() + .flex() + .gap_3() + .px_3() + .py_2() + .items_center() + .child( + div() + .absolute() + .left_0() + .top_0() + .w(px(2.)) + .h_full() + .bg(cx.theme().border_transparent) + .group_hover("", |this| this.bg(red())), + ) + .child(img("brand/avatar.png").size_8().flex_shrink_0()) + .text_sm() + .text_color(red()) + .child(content.clone()) + } + + fn render_announcement_msg(&mut self, cx: &Context) -> Div { + div() + .group("") + .w_full() + .relative() + .flex() + .gap_3() + .px_3() + .py_2() + .w_full() + .h_32() + .flex() + .flex_col() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_placeholder) + .line_height(relative(1.3)) + .child( + svg() + .path("brand/coop.svg") + .size_10() + .text_color(cx.theme().elevated_surface_background), + ) + .child(DESC) } } @@ -474,27 +557,7 @@ impl Panel for Chat { .flex() .items_center() .gap_1p5() - .map(|this| { - if let Some(url) = url { - this.child(img(url).size_5().flex_shrink_0()) - } else { - this.child( - div() - .flex_shrink_0() - .flex() - .justify_center() - .items_center() - .size_5() - .rounded_full() - .bg(cx.theme().element_disabled) - .child( - Icon::new(IconName::UsersThreeFill) - .xsmall() - .text_color(cx.theme().icon_accent), - ), - ) - } - }) + .child(img(url).size_5().flex_shrink_0()) .child(label) .into_any() }) @@ -592,7 +655,7 @@ impl Render for Chat { div() .w_full() .flex() - .items_center() + .items_end() .gap_2p5() .child( div() @@ -604,8 +667,8 @@ impl Render for Chat { Button::new("upload") .icon(Icon::new(IconName::Upload)) .ghost() - .disabled(self.is_uploading) - .loading(self.is_uploading) + .disabled(self.uploading) + .loading(self.uploading) .on_click(cx.listener( move |this, _, window, cx| { this.upload_media(window, cx); @@ -617,7 +680,7 @@ impl Render for Chat { .icon(IconName::EmojiFill), ), ) - .child(self.input.clone()), + .child(TextInput::new(&self.input)), ), ), ) diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 9e3e4f2..1d09e7d 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -21,8 +21,8 @@ use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, dock_area::dock::DockPlacement, - input::{InputEvent, TextInput}, - ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt, + input::{InputEvent, InputState, TextInput}, + ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, }; use crate::chatspace::{AddPanel, PanelKind}; @@ -37,8 +37,8 @@ struct SelectContact(PublicKey); impl_internal_actions!(contacts, [SelectContact]); pub struct Compose { - title_input: Entity, - user_input: Entity, + title_input: Entity, + user_input: Entity, contacts: Entity>, selected: Entity>, focus_handle: FocusHandle, @@ -55,18 +55,9 @@ impl Compose { let selected = cx.new(|_| HashSet::new()); let error_message = cx.new(|_| None); - let title_input = cx.new(|cx| { - TextInput::new(window, cx) - .appearance(false) - .placeholder("Family... . (Optional)") - .text_size(Size::Small) - }); - - let user_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(ui::Size::Small) - .placeholder("npub1...") - }); + let user_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1...")); + let title_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)")); let mut subscriptions = smallvec![]; @@ -75,7 +66,7 @@ impl Compose { &user_input, window, move |this, _, input_event, window, cx| { - if let InputEvent::PressEnter = input_event { + if let InputEvent::PressEnter { .. } = input_event { this.add(window, cx); } }, @@ -135,10 +126,10 @@ impl Compose { let mut tag_list: Vec = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect(); // Add subject if it is present - if !self.title_input.read(cx).text().is_empty() { + if !self.title_input.read(cx).value().is_empty() { tag_list.push(Tag::custom( TagKind::Subject, - vec![self.title_input.read(cx).text().to_string()], + vec![self.title_input.read(cx).value().to_string()], )); } @@ -163,7 +154,7 @@ impl Compose { Ok(event) => { cx.update(|window, cx| { ChatRegistry::global(cx).update(cx, |chats, cx| { - let id = chats.push_event(&event, window, cx); + let id = chats.event_to_room(&event, window, cx); window.close_modal(cx); window.dispatch_action( Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)), @@ -185,7 +176,7 @@ impl Compose { fn add(&mut self, window: &mut Window, cx: &mut Context) { let client = get_client(); - let content = self.user_input.read(cx).text().to_string(); + let content = self.user_input.read(cx).value().to_string(); // Show loading spinner self.set_loading(true, cx); @@ -241,8 +232,7 @@ impl Compose { // Clear input this.user_input.update(cx, |this, cx| { - this.set_text("", window, cx); - cx.notify(); + this.set_value("", window, cx); }); }) .ok(); @@ -349,7 +339,7 @@ impl Render for Compose { .items_center() .gap_1() .child(div().pb_0p5().text_sm().font_semibold().child("Subject:")) - .child(self.title_input.clone()), + .child(TextInput::new(&self.title_input).small().appearance(false)), ), ) .child( @@ -365,7 +355,7 @@ impl Render for Compose { .flex_col() .gap_2() .child(div().text_sm().font_semibold().child("To:")) - .child(self.user_input.clone()), + .child(TextInput::new(&self.user_input).small()), ) .map(|this| { let contacts = self.contacts.read(cx).clone(); diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index 9034987..20c445d 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use account::Account; -use common::create_qr; +use common::string_to_qr; use global::get_client_keys; use gpui::{ div, img, prelude::FluentBuilder, red, relative, AnyElement, App, AppContext, Context, Entity, @@ -14,10 +14,10 @@ use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, - input::{InputEvent, TextInput}, + input::{InputEvent, InputState, TextInput}, notification::Notification, popup_menu::PopupMenu, - ContextModal, Disableable, Sizable, Size, StyledExt, + ContextModal, Disableable, Sizable, StyledExt, }; #[derive(Debug, Clone)] @@ -38,12 +38,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Login { // Inputs - key_input: Entity, + key_input: Entity, error: Entity>, is_logging_in: bool, // Nostr Connect qr: Entity>>, - connect_relay: Entity, + connect_relay: Entity, connect_client: Entity>, // Keep track of all signers created by nostr connect signers: SmallVec<[NostrConnect; 3]>, @@ -66,26 +66,19 @@ impl Login { let error = cx.new(|_| None); let qr = cx.new(|_| None); + let key_input = + cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://...")); + let connect_relay = + cx.new(|cx| InputState::new(window, cx).default_value("wss://relay.nsec.app")); + let signers = smallvec![]; let mut subscriptions = smallvec![]; - let key_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) - .placeholder("nsec... or bunker://...") - }); - - let connect_relay = cx.new(|cx| { - let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small(); - input.set_text("wss://relay.nsec.app", window, cx); - input - }); - subscriptions.push(cx.subscribe_in( &key_input, window, move |this, _, event, window, cx| { - if let InputEvent::PressEnter = event { + if let InputEvent::PressEnter { .. } = event { this.login(window, cx); } }, @@ -95,7 +88,7 @@ impl Login { &connect_relay, window, move |this, _, event, window, cx| { - if let InputEvent::PressEnter = event { + if let InputEvent::PressEnter { .. } = event { this.change_relay(window, cx); } }, @@ -106,7 +99,7 @@ impl Login { let keys = get_client_keys().to_owned(); if let Some(uri) = uri.read(cx).clone() { - if let Ok(qr) = create_qr(uri.to_string().as_str()) { + if let Ok(qr) = string_to_qr(uri.to_string().as_str()) { this.qr.update(cx, |this, cx| { *this = Some(qr); cx.notify(); @@ -179,7 +172,7 @@ impl Login { self.set_logging_in(true, cx); - let content = self.key_input.read(cx).text(); + let content = self.key_input.read(cx).value(); let account = Account::global(cx); if content.starts_with("nsec1") { @@ -212,7 +205,7 @@ impl Login { fn change_relay(&mut self, window: &mut Window, cx: &mut Context) { let Ok(relay_url) = - RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str()) + RelayUrl::parse(self.connect_relay.read(cx).value().to_string().as_str()) else { window.push_notification(Notification::error("Relay URL is not valid."), cx); return; @@ -316,7 +309,7 @@ impl Render for Login { .flex() .flex_col() .gap_3() - .child(self.key_input.clone()) + .child(TextInput::new(&self.key_input)) .child( Button::new("login") .label("Continue") @@ -401,7 +394,7 @@ impl Render for Login { .items_center() .justify_center() .gap_1() - .child(self.connect_relay.clone()) + .child(TextInput::new(&self.connect_relay).xsmall()) .child( Button::new("change") .label("Change") diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index 5d15142..f575078 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -15,9 +15,9 @@ use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, - input::TextInput, + input::{InputState, TextInput}, popup_menu::PopupMenu, - Disableable, Icon, IconName, Sizable, Size, StyledExt, + Disableable, Icon, IconName, Sizable, StyledExt, }; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -25,9 +25,9 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { } pub struct NewAccount { - name_input: Entity, - avatar_input: Entity, - bio_input: Entity, + name_input: Entity, + avatar_input: Entity, + bio_input: Entity, is_uploading: bool, is_submitting: bool, // Panel @@ -43,22 +43,11 @@ impl NewAccount { } fn view(window: &mut Window, cx: &mut Context) -> Self { - let name_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) - .placeholder("Alice") - }); - - let avatar_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) - .small() - .placeholder("https://example.com/avatar.jpg") - }); - + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); + let avatar_input = + cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg")); let bio_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) + InputState::new(window, cx) .multi_line() .placeholder("A short introduce about you.") }); @@ -79,9 +68,9 @@ impl NewAccount { fn submit(&mut self, window: &mut Window, cx: &mut Context) { self.set_submitting(true, cx); - let avatar = self.avatar_input.read(cx).text().to_string(); - let name = self.name_input.read(cx).text().to_string(); - let bio = self.bio_input.read(cx).text().to_string(); + let avatar = self.avatar_input.read(cx).value().to_string(); + let name = self.name_input.read(cx).value().to_string(); + let bio = self.bio_input.read(cx).value().to_string(); let mut metadata = Metadata::new().display_name(name).about(bio); @@ -140,7 +129,7 @@ impl NewAccount { // Set avatar input avatar_input .update(cx, |this, cx| { - this.set_text(url.to_string(), window, cx); + this.set_value(url.to_string(), window, cx); }) .ok(); }) @@ -241,14 +230,14 @@ impl Render for NewAccount { .justify_center() .gap_2() .map(|this| { - if self.avatar_input.read(cx).text().is_empty() { + if self.avatar_input.read(cx).value().is_empty() { this.child(img("brand/avatar.png").size_10().flex_shrink_0()) } else { this.child( img(format!( "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", IMAGE_SERVICE, - self.avatar_input.read(cx).text() + self.avatar_input.read(cx).value() )) .size_10() .flex_shrink_0(), @@ -275,7 +264,7 @@ impl Render for NewAccount { .gap_1() .text_sm() .child("Name *:") - .child(self.name_input.clone()), + .child(TextInput::new(&self.name_input).small()), ) .child( div() @@ -284,7 +273,7 @@ impl Render for NewAccount { .gap_1() .text_sm() .child("Bio:") - .child(self.bio_input.clone()), + .child(TextInput::new(&self.bio_input).small()), ) .child( div() diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/profile.rs index e6f25d1..29bbedb 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/profile.rs @@ -11,8 +11,8 @@ use std::{str::FromStr, time::Duration}; use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, - input::TextInput, - ContextModal, Disableable, IconName, Sizable, Size, + input::{InputState, TextInput}, + ContextModal, Disableable, IconName, Sizable, }; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -21,38 +21,23 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Profile { profile: Option, - name_input: Entity, - avatar_input: Entity, - bio_input: Entity, - website_input: Entity, + name_input: Entity, + avatar_input: Entity, + bio_input: Entity, + website_input: Entity, is_loading: bool, is_submitting: bool, } impl Profile { pub fn new(window: &mut Window, cx: &mut App) -> Entity { - let name_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) - .placeholder("Alice") - }); - - let avatar_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::XSmall) - .small() - .placeholder("https://example.com/avatar.jpg") - }); - - let website_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) - .placeholder("https://your-website.com") - }); - + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); + let avatar_input = + cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg")); + let website_input = + cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com")); let bio_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::Small) + InputState::new(window, cx) .multi_line() .placeholder("A short introduce about you.") }); @@ -85,26 +70,25 @@ impl Profile { this.update(cx, |this: &mut Profile, cx| { this.avatar_input.update(cx, |this, cx| { if let Some(avatar) = metadata.picture.as_ref() { - this.set_text(avatar, window, cx); + this.set_value(avatar, window, cx); } }); this.bio_input.update(cx, |this, cx| { if let Some(bio) = metadata.about.as_ref() { - this.set_text(bio, window, cx); + this.set_value(bio, window, cx); } }); this.name_input.update(cx, |this, cx| { if let Some(display_name) = metadata.display_name.as_ref() { - this.set_text(display_name, window, cx); + this.set_value(display_name, window, cx); } }); this.website_input.update(cx, |this, cx| { if let Some(website) = metadata.website.as_ref() { - this.set_text(website, window, cx); + this.set_value(website, window, cx); } }); this.profile = Some(metadata); - cx.notify(); }) .ok(); @@ -155,7 +139,7 @@ impl Profile { // Set avatar input avatar_input .update(cx, |this, cx| { - this.set_text(url.to_string(), window, cx); + this.set_value(url.to_string(), window, cx); }) .ok(); }) @@ -183,10 +167,10 @@ impl Profile { // Show loading spinner self.set_submitting(true, cx); - let avatar = self.avatar_input.read(cx).text().to_string(); - let name = self.name_input.read(cx).text().to_string(); - let bio = self.bio_input.read(cx).text().to_string(); - let website = self.website_input.read(cx).text().to_string(); + let avatar = self.avatar_input.read(cx).value().to_string(); + let name = self.name_input.read(cx).value().to_string(); + let bio = self.bio_input.read(cx).value().to_string(); + let website = self.website_input.read(cx).value().to_string(); let old_metadata = if let Some(metadata) = self.profile.as_ref() { metadata.clone() @@ -257,16 +241,14 @@ impl Render for Profile { .justify_center() .gap_2() .map(|this| { - let picture = self.avatar_input.read(cx).text(); - + let picture = self.avatar_input.read(cx).value(); if picture.is_empty() { this.child(img("brand/avatar.png").size_10().flex_shrink_0()) } else { this.child( img(format!( "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, - self.avatar_input.read(cx).text() + IMAGE_SERVICE, picture )) .size_10() .flex_shrink_0(), @@ -293,7 +275,7 @@ impl Render for Profile { .gap_1() .text_sm() .child("Name:") - .child(self.name_input.clone()), + .child(TextInput::new(&self.name_input).small()), ) .child( div() @@ -302,7 +284,7 @@ impl Render for Profile { .gap_1() .text_sm() .child("Website:") - .child(self.website_input.clone()), + .child(TextInput::new(&self.website_input).small()), ) .child( div() @@ -311,7 +293,7 @@ impl Render for Profile { .gap_1() .text_sm() .child("Bio:") - .child(self.bio_input.clone()), + .child(TextInput::new(&self.bio_input).small()), ) .child( div().py_3().child( diff --git a/crates/coop/src/views/relays.rs b/crates/coop/src/views/relays.rs index e6c8387..ad0ba2e 100644 --- a/crates/coop/src/views/relays.rs +++ b/crates/coop/src/views/relays.rs @@ -10,7 +10,7 @@ use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, - input::{InputEvent, TextInput}, + input::{InputEvent, InputState, TextInput}, ContextModal, Disableable, IconName, Sizable, }; @@ -24,7 +24,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Relays { relays: Entity>, - input: Entity, + input: Entity, focus_handle: FocusHandle, is_loading: bool, #[allow(dead_code)] @@ -33,13 +33,7 @@ pub struct Relays { impl Relays { pub fn new(window: &mut Window, cx: &mut App) -> Entity { - let input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(ui::Size::XSmall) - .small() - .placeholder("wss://example.com") - }); - + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); let relays = cx.new(|cx| { let task: Task, Error>> = cx.background_spawn(async move { let client = get_client(); @@ -92,8 +86,8 @@ impl Relays { subscriptions.push(cx.subscribe_in( &input, window, - move |this: &mut Relays, _, input_event, window, cx| { - if let InputEvent::PressEnter = input_event { + move |this: &mut Relays, _, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { this.add(window, cx); } }, @@ -190,7 +184,7 @@ impl Relays { } fn add(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).text().to_string(); + let value = self.input.read(cx).value().to_string(); if !value.starts_with("ws") { return; @@ -205,7 +199,7 @@ impl Relays { }); self.input.update(cx, |this, cx| { - this.set_text("", window, cx); + this.set_value("", window, cx); }); } } @@ -313,7 +307,7 @@ impl Render for Relays { .items_center() .w_full() .gap_2() - .child(self.input.clone()) + .child(TextInput::new(&self.input).small()) .child( Button::new("add_relay_btn") .icon(IconName::Plus) diff --git a/crates/coop/src/views/sidebar/folder.rs b/crates/coop/src/views/sidebar/folder.rs index 53ac45c..5b41de2 100644 --- a/crates/coop/src/views/sidebar/folder.rs +++ b/crates/coop/src/views/sidebar/folder.rs @@ -264,8 +264,8 @@ impl FolderItem { self } - pub fn img(mut self, img: Option) -> Self { - self.img = img; + pub fn img(mut self, img: Img) -> Self { + self.img = Some(img); self } @@ -286,49 +286,43 @@ impl RenderOnce for FolderItem { .id(self.ix) .flex() .items_center() - .justify_between() + .gap_2() .text_sm() .rounded(cx.theme().radius) + .child(div().size_6().flex_none().map(|this| { + if let Some(img) = self.img { + this.child(img.size_6().flex_none()) + } else { + this.child( + div() + .size_6() + .flex_none() + .flex() + .justify_center() + .items_center() + .rounded_full() + .bg(cx.theme().element_background), + ) + } + })) .child( div() .flex_1() .flex() .items_center() - .gap_2() - .truncate() - .font_medium() - .map(|this| { - if let Some(img) = self.img { - this.child(img.size_6().flex_shrink_0()) - } else { - this.child( - div() - .flex_shrink_0() - .flex() - .justify_center() - .items_center() - .size_5() - .rounded_full() - .bg(cx.theme().element_disabled) - .child( - Icon::new(IconName::UsersThreeFill) - .xsmall() - .text_color(cx.theme().text_accent), - ), - ) - } + .justify_between() + .when_some(self.label, |this, label| { + this.child(div().truncate().text_ellipsis().font_medium().child(label)) }) - .when_some(self.label, |this, label| this.child(label)), + .when_some(self.description, |this, description| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(description), + ) + }), ) - .when_some(self.description, |this, description| { - this.child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(description), - ) - }) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .on_click(move |ev, window, cx| handler(ev, window, cx)) } diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 14420e6..c6b32e7 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -1,5 +1,4 @@ use std::{ - cmp::Reverse, collections::{BTreeSet, HashSet}, time::Duration, }; @@ -29,7 +28,7 @@ use ui::{ dock::DockPlacement, panel::{Panel, PanelEvent}, }, - input::{InputEvent, TextInput}, + input::{InputEvent, InputState, TextInput}, popup_menu::{PopupMenu, PopupMenuExt}, skeleton::Skeleton, IconName, Sizable, StyledExt, @@ -61,13 +60,13 @@ pub enum SubItem { pub struct Sidebar { name: SharedString, // Search - find_input: Entity, + find_input: Entity, find_debouncer: DebouncedDelay, finding: bool, local_result: Entity>>>, global_result: Entity>>>, // Layout - split_into_folders: bool, + folders: bool, active_items: HashSet, active_subitems: HashSet, // GPUI @@ -95,32 +94,16 @@ impl Sidebar { let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); - let find_input = cx.new(|cx| { - TextInput::new(window, cx) - .small() - .text_size(ui::Size::XSmall) - .suffix(|window, cx| { - Button::new("find") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .small() - .custom( - ButtonCustomVariant::new(window, cx) - .active(gpui::transparent_black()) - .color(gpui::transparent_black()) - .hover(gpui::transparent_black()) - .foreground(cx.theme().text_placeholder), - ) - }) - .placeholder("Find or start a conversation") - }); + + let find_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); let mut subscriptions = smallvec![]; subscriptions.push( cx.subscribe_in(&find_input, window, |this, _, event, _, cx| { match event { - InputEvent::PressEnter => this.search(cx), + InputEvent::PressEnter { .. } => this.search(cx), InputEvent::Change(text) => { // Clear the result when input is empty if text.is_empty() { @@ -141,7 +124,7 @@ impl Sidebar { Self { name: "Chat Sidebar".into(), - split_into_folders: false, + folders: false, find_debouncer: DebouncedDelay::new(), finding: false, find_input, @@ -170,7 +153,7 @@ impl Sidebar { } fn toggle_folder(&mut self, cx: &mut Context) { - self.split_into_folders = !self.split_into_folders; + self.folders = !self.folders; cx.notify(); } @@ -184,7 +167,7 @@ impl Sidebar { } fn nip50_search(&self, cx: &App) -> Task, Error>> { - let query = self.find_input.read(cx).text(); + let query = self.find_input.read(cx).value().clone(); cx.background_spawn(async move { let client = get_client(); @@ -236,7 +219,7 @@ impl Sidebar { } fn search(&mut self, cx: &mut Context) { - let query = self.find_input.read(cx).text(); + let query = self.find_input.read(cx).value(); let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx); // Return if query is empty @@ -336,7 +319,7 @@ impl Sidebar { div() .h_8() .w_full() - .px_1() + .px_2() .flex() .items_center() .gap_2() @@ -428,11 +411,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let account = Account::get_global(cx).profile_ref(); - let registry = ChatRegistry::get_global(cx); - - // Get all rooms - let rooms = registry.rooms(cx); - let loading = registry.loading; + let chats = ChatRegistry::get_global(cx); // Get search result let local_result = self.local_result.read(cx); @@ -513,11 +492,21 @@ impl Render for Sidebar { ) }) .child( - div() - .px_3() - .h_7() - .flex_none() - .child(self.find_input.clone()), + div().px_3().h_7().flex_none().child( + TextInput::new(&self.find_input).small().suffix( + Button::new("find") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .small() + .custom( + ButtonCustomVariant::new(window, cx) + .active(gpui::transparent_black()) + .color(gpui::transparent_black()) + .hover(gpui::transparent_black()) + .foreground(cx.theme().text_placeholder), + ), + ), + ), ) .when_some(global_result.as_ref(), |this, rooms| { this.child( @@ -550,7 +539,7 @@ impl Render for Sidebar { Button::new("menu") .tooltip("Toggle chat folders") .map(|this| { - if self.split_into_folders { + if self.folders { this.icon(IconName::FilterFill) } else { this.icon(IconName::Filter) @@ -569,35 +558,28 @@ impl Render for Sidebar { })), ), ) - .when(loading, |this| this.children(self.render_skeleton(6))) + .when(chats.wait_for_eose, |this| { + this.px_2().children(self.render_skeleton(6)) + }) .map(|this| { if let Some(rooms) = local_result { this.children(Self::render_items(rooms, cx)) - } else if !self.split_into_folders { - let rooms = rooms - .values() - .flat_map(|v| v.iter().cloned()) - .sorted_by_key(|e| Reverse(e.read(cx).created_at)) - .collect_vec(); - - this.children(Self::render_items(&rooms, cx)) + } else if !self.folders { + this.children(Self::render_items(&chats.rooms, cx)) } else { - let ongoing = rooms.get(&RoomKind::Ongoing); - let trusted = rooms.get(&RoomKind::Trusted); - let unknown = rooms.get(&RoomKind::Unknown); - - this.when_some(ongoing, |this, rooms| { - this.child( - Folder::new("Ongoing") - .icon(IconName::Folder) - .tooltip("All ongoing conversations") - .collapsed(!self.active_items.contains(&Item::Ongoing)) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_item(Item::Ongoing, cx); - })) - .children(Self::render_items(rooms, cx)), - ) - }) + this.child( + Folder::new("Ongoing") + .icon(IconName::Folder) + .tooltip("All ongoing conversations") + .collapsed(!self.active_items.contains(&Item::Ongoing)) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_item(Item::Ongoing, cx); + })) + .children(Self::render_items( + &chats.rooms_by_kind(RoomKind::Ongoing, cx), + cx, + )), + ) .child( Parent::new("Incoming") .icon(IconName::Folder) @@ -606,38 +588,36 @@ impl Render for Sidebar { .on_click(cx.listener(move |this, _, _, cx| { this.toggle_item(Item::Incoming, cx); })) - .when_some(trusted, |this, rooms| { - this.child( - Folder::new("Trusted") - .icon(IconName::Folder) - .tooltip("Incoming messages from trusted contacts") - .collapsed( - !self - .active_subitems - .contains(&SubItem::Trusted), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_subitem(SubItem::Trusted, cx); - })) - .children(Self::render_items(rooms, cx)), - ) - }) - .when_some(unknown, |this, rooms| { - this.child( - Folder::new("Unknown") - .icon(IconName::Folder) - .tooltip("Incoming messages from unknowns") - .collapsed( - !self - .active_subitems - .contains(&SubItem::Unknown), - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_subitem(SubItem::Unknown, cx); - })) - .children(Self::render_items(rooms, cx)), - ) - }), + .child( + Folder::new("Trusted") + .icon(IconName::Folder) + .tooltip("Incoming messages from trusted contacts") + .collapsed( + !self.active_subitems.contains(&SubItem::Trusted), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_subitem(SubItem::Trusted, cx); + })) + .children(Self::render_items( + &chats.rooms_by_kind(RoomKind::Trusted, cx), + cx, + )), + ) + .child( + Folder::new("Unknown") + .icon(IconName::Folder) + .tooltip("Incoming messages from unknowns") + .collapsed( + !self.active_subitems.contains(&SubItem::Unknown), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_subitem(SubItem::Unknown, cx); + })) + .children(Self::render_items( + &chats.rooms_by_kind(RoomKind::Unknown, cx), + cx, + )), + ), ) } }), diff --git a/crates/coop/src/views/subject.rs b/crates/coop/src/views/subject.rs index 2b114bd..cedd474 100644 --- a/crates/coop/src/views/subject.rs +++ b/crates/coop/src/views/subject.rs @@ -6,8 +6,8 @@ use gpui::{ use theme::ActiveTheme; use ui::{ button::{Button, ButtonVariants}, - input::TextInput, - ContextModal, Size, + input::{InputState, TextInput}, + ContextModal, Sizable, }; pub fn init( @@ -21,7 +21,7 @@ pub fn init( pub struct Subject { id: u64, - input: Entity, + input: Entity, focus_handle: FocusHandle, } @@ -33,11 +33,9 @@ impl Subject { cx: &mut App, ) -> Entity { let input = cx.new(|cx| { - let mut this = TextInput::new(window, cx).text_size(Size::Small); + let mut this = InputState::new(window, cx).placeholder("Exciting Project..."); if let Some(text) = subject.clone() { - this.set_text(text, window, cx); - } else { - this.set_placeholder("prepare for holidays..."); + this.set_value(text, window, cx); } this }); @@ -51,7 +49,7 @@ impl Subject { pub fn update(&mut self, window: &mut Window, cx: &mut Context) { let registry = ChatRegistry::global(cx).read(cx); - let subject = self.input.read(cx).text(); + let subject = self.input.read(cx).value().clone(); if let Some(room) = registry.room(&self.id, cx) { room.update(cx, |this, cx| { @@ -88,7 +86,7 @@ impl Render for Subject { .text_color(cx.theme().text_muted) .child("Subject:"), ) - .child(self.input.clone()) + .child(TextInput::new(&self.input).small()) .child( div() .text_xs() diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs new file mode 100644 index 0000000..17acbb6 --- /dev/null +++ b/crates/ui/src/actions.rs @@ -0,0 +1,11 @@ +use gpui::{actions, impl_internal_actions}; +use serde::Deserialize; + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub struct Confirm { + /// Is confirm with secondary. + pub secondary: bool, +} + +actions!(list, [Cancel, SelectPrev, SelectNext]); +impl_internal_actions!(list, [Confirm]); diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 58453d2..cbb1251 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -1,28 +1,42 @@ use gpui::{ - actions, anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, App, - AppContext, Bounds, ClickEvent, Context, DismissEvent, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, - Pixels, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, - WeakEntity, Window, + anchored, canvas, deferred, div, prelude::FluentBuilder, 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, SelectNext, SelectPrev}, h_flex, - list::{self, List, ListDelegate, ListItem}, - v_flex, Icon, IconName, Sizable, Size, StyleSized, StyledExt, + input::clear_button::clear_button, + list::{List, ListDelegate, ListItem}, + v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized, }; -actions!(dropdown, [Up, Down, Enter, Escape]); +#[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, +} const CONTEXT: &str = "Dropdown"; - pub fn init(cx: &mut App) { cx.bind_keys([ - KeyBinding::new("up", Up, Some(CONTEXT)), - KeyBinding::new("down", Down, Some(CONTEXT)), - KeyBinding::new("enter", Enter, Some(CONTEXT)), - KeyBinding::new("escape", Escape, Some(CONTEXT)), + KeyBinding::new("up", SelectPrev, Some(CONTEXT)), + KeyBinding::new("down", SelectNext, 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)), ]) } @@ -30,6 +44,12 @@ pub fn init(cx: &mut App) { pub trait DropdownItem { type Value: Clone; fn title(&self) -> SharedString; + /// Customize the display title used to selected item in Dropdown Input. + /// + /// If return None, the title will be used. + fn display_title(&self) -> Option { + None + } fn value(&self) -> &Self::Value; } @@ -80,12 +100,7 @@ pub trait DropdownDelegate: Sized { false } - fn perform_search( - &mut self, - _query: &str, - _window: &mut Window, - _cx: &mut Context>, - ) -> Task<()> { + fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> { Task::ready(()) } } @@ -112,7 +127,7 @@ impl DropdownDelegate for Vec { struct DropdownListDelegate { delegate: D, - dropdown: WeakEntity>, + dropdown: WeakEntity>, selected_index: Option, } @@ -126,14 +141,10 @@ where self.delegate.len() } - fn confirmed_index(&self, _: &App) -> Option { - self.selected_index - } - fn render_item( &self, ix: usize, - _window: &mut gpui::Window, + _: &mut gpui::Window, cx: &mut gpui::Context>, ) -> Option { let selected = self.selected_index == Some(ix); @@ -145,9 +156,8 @@ where if let Some(item) = self.delegate.get(ix) { let list_item = ListItem::new(("list-item", ix)) .check_icon(IconName::Check) - .cursor_pointer() .selected(selected) - .input_text_size(size) + .input_font_size(size) .list_size(size) .child(div().whitespace_nowrap().child(item.title().to_string())); Some(list_item) @@ -166,9 +176,7 @@ where }); } - fn confirm(&mut self, ix: Option, window: &mut Window, cx: &mut Context>) { - self.selected_index = ix; - + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { let selected_value = self .selected_index .and_then(|ix| self.delegate.get(ix)) @@ -227,27 +235,35 @@ pub enum DropdownEvent { Confirm(Option<::Value>), } -type Empty = Option AnyElement + 'static>>; +type DropdownStateEmpty = Option AnyElement>>; -/// A Dropdown element. -pub struct Dropdown { - id: ElementId, +/// State of the [`Dropdown`]. +pub struct DropdownState { focus_handle: FocusHandle, list: Entity>>, size: Size, - icon: Option, - open: bool, - placeholder: Option, - title_prefix: Option, - selected_value: Option<::Value>, - empty: Empty, - width: Length, - menu_width: Length, + empty: DropdownStateEmpty, /// Store the bounds of the input bounds: Bounds, + open: bool, + selected_value: Option<::Value>, + _subscriptions: Vec, +} + +/// A Dropdown element. +#[derive(IntoElement)] +pub struct Dropdown { + id: ElementId, + state: Entity>, + size: Size, + icon: Option, + cleanable: bool, + placeholder: Option, + title_prefix: Option, + empty: Option, + width: Length, + menu_width: Length, disabled: bool, - #[allow(dead_code)] - subscriptions: Vec, } pub struct SearchableVec { @@ -258,7 +274,6 @@ pub struct SearchableVec { impl SearchableVec { pub fn new(items: impl Into>) -> Self { let items = items.into(); - Self { items: items.clone(), matched_items: items, @@ -295,12 +310,7 @@ impl DropdownDelegate for SearchableVec { true } - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - _cx: &mut Context>, - ) -> Task<()> { + fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> { self.matched_items = self .items .iter() @@ -321,12 +331,11 @@ impl From> for SearchableVec { } } -impl Dropdown +impl DropdownState where D: DropdownDelegate + 'static, { pub fn new( - id: impl Into, delegate: D, selected_index: Option, window: &mut Window, @@ -342,83 +351,34 @@ where let searchable = delegate.delegate.can_search(); let list = cx.new(|cx| { - let mut list = List::new(delegate, window, cx).max_h(rems(20.)); + 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![ + 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 { - id: id.into(), focus_handle, - placeholder: None, list, size: Size::Medium, - icon: None, selected_value: None, open: false, - title_prefix: None, - empty: None, - width: Length::Auto, - menu_width: Length::Auto, bounds: Bounds::default(), - disabled: false, - subscriptions, + empty: None, + _subscriptions, }; this.set_selected_index(selected_index, window, cx); this } - /// Set the width of the dropdown input, default: Length::Auto - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - /// Set the width of the dropdown menu, default: Length::Auto - pub fn menu_width(mut self, width: impl Into) -> Self { - self.menu_width = width.into(); - self - } - - /// Set the placeholder for display when dropdown value is empty. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = Some(placeholder.into()); - self - } - - /// Set the right icon for the dropdown input, instead of the default arrow icon. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - /// Set title prefix for the dropdown. - /// - /// e.g.: Country: United States - /// - /// You should set the label is `Country: ` - pub fn title_prefix(mut self, prefix: impl Into) -> Self { - self.title_prefix = Some(prefix.into()); - self - } - - /// Set the disable state for the dropdown. - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - pub fn set_disabled(&mut self, disabled: bool) { - self.disabled = disabled; - } - pub fn empty(mut self, f: F) -> Self where E: IntoElement, @@ -453,13 +413,13 @@ where self.set_selected_index(selected_index, window, cx); } - pub fn selected_index(&self, _window: &Window, cx: &App) -> Option { + pub fn selected_index(&self, cx: &App) -> Option { self.list.read(cx).selected_index() } - fn update_selected_value(&mut self, window: &Window, cx: &App) { + fn update_selected_value(&mut self, _: &Window, cx: &App) { self.selected_value = self - .selected_index(window, cx) + .selected_index(cx) .and_then(|ix| self.list.read(cx).delegate().delegate.get(ix)) .map(|item| item.value().clone()); } @@ -482,24 +442,25 @@ where cx.notify(); } - fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { + fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context) { if !self.open { return; } + self.list.focus_handle(cx).focus(window); - window.dispatch_action(Box::new(list::SelectPrev), cx); + cx.propagate(); } - fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { + fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if !self.open { self.open = true; } self.list.focus_handle(cx).focus(window); - window.dispatch_action(Box::new(list::SelectNext), cx); + cx.propagate(); } - fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { + fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { // Propagate the event to the parent view, for example to the Modal to support ENTER to confirm. cx.propagate(); @@ -508,7 +469,6 @@ where cx.notify(); } else { self.list.focus_handle(cx).focus(window); - window.dispatch_action(Box::new(list::Confirm), cx); } } @@ -522,39 +482,150 @@ where cx.notify(); } - fn escape(&mut self, _: &Escape, _window: &mut Window, cx: &mut Context) { - // Propagate the event to the parent view, for example to the Modal to support ESC to close. - cx.propagate(); + fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) { + if !self.open { + cx.propagate(); + } self.open = false; cx.notify(); } - fn display_title(&self, window: &Window, cx: &App) -> impl IntoElement { - let title = if let Some(selected_index) = &self.selected_index(window, cx) { - let title = self - .list - .read(cx) - .delegate() - .delegate - .get(*selected_index) - .map(|item| item.title().to_string()) - .unwrap_or_default(); + fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.set_selected_index(None, window, cx); + cx.emit(DropdownEvent::Confirm(None)); + } - h_flex() - .when_some(self.title_prefix.clone(), |this, prefix| this.child(prefix)) - .child(title.clone()) - } else { - div().text_color(cx.theme().text_accent).child( + /// Set the items for the dropdown. + pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) + where + D: DropdownDelegate + 'static, + { + self.list.update(cx, |list, _| { + list.delegate_mut().delegate = items; + }); + } +} + +impl Render for DropdownState +where + D: DropdownDelegate + 'static, +{ + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +impl Dropdown +where + D: DropdownDelegate + 'static, +{ + pub fn new(state: &Entity>) -> Self { + Self { + id: ("dropdown", state.entity_id()).into(), + state: state.clone(), + placeholder: None, + size: Size::Medium, + icon: None, + cleanable: false, + title_prefix: None, + empty: None, + width: Length::Auto, + menu_width: Length::Auto, + disabled: false, + } + } + + /// Set the width of the dropdown input, default: Length::Auto + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Set the width of the dropdown menu, default: Length::Auto + pub fn menu_width(mut self, width: impl Into) -> Self { + self.menu_width = width.into(); + self + } + + /// Set the placeholder for display when dropdown value is empty. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Set the right icon for the dropdown input, instead of the default arrow icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + /// Set title prefix for the dropdown. + /// + /// e.g.: Country: United States + /// + /// You should set the label is `Country: ` + pub fn title_prefix(mut self, prefix: impl Into) -> Self { + self.title_prefix = Some(prefix.into()); + self + } + + /// Set true to show the clear button when the input field is not empty. + pub fn cleanable(mut self) -> Self { + self.cleanable = true; + self + } + + /// Set the disable state for the dropdown. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn empty(mut self, el: impl IntoElement) -> Self { + self.empty = Some(el.into_any_element()); + self + } + + /// Returns the title element for the dropdown input. + fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement { + let default_title = div() + .text_color(cx.theme().text_accent) + .child( self.placeholder .clone() .unwrap_or_else(|| "Please select".into()), ) + .when(self.disabled, |this| this.text_color(cx.theme().text_muted)); + + let Some(selected_index) = &self.state.read(cx).selected_index(cx) else { + return default_title; }; - title.when(self.disabled, |this| { - this.cursor_not_allowed().text_color(cx.theme().text_muted) - }) + 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) } } @@ -568,11 +639,11 @@ where } } -impl EventEmitter> for Dropdown where D: DropdownDelegate + 'static {} -impl EventEmitter for Dropdown where D: DropdownDelegate + 'static {} -impl Focusable for Dropdown +impl EventEmitter> for DropdownState where D: DropdownDelegate + 'static {} +impl EventEmitter for DropdownState where D: DropdownDelegate + 'static {} +impl Focusable for DropdownState where - D: DropdownDelegate + 'static, + D: DropdownDelegate, { fn focus_handle(&self, cx: &App) -> FocusHandle { if self.open { @@ -582,38 +653,55 @@ where } } } +impl Focusable for Dropdown +where + D: DropdownDelegate, +{ + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.state.focus_handle(cx) + } +} -impl Render for Dropdown +impl RenderOnce for Dropdown where D: DropdownDelegate + 'static, { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_focused = self.focus_handle.is_focused(window); - let view = cx.entity().clone(); - let bounds = self.bounds; - let allow_open = !(self.open || self.disabled); - let outline_visible = self.open || is_focused && !self.disabled; - + 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. - if self.list.read(cx).size != self.size { - self.list - .update(cx, |this, cx| this.set_size(self.size, window, cx)) + 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) - .on_action(cx.listener(Self::up)) - .on_action(cx.listener(Self::down)) - .on_action(cx.listener(Self::enter)) - .on_action(cx.listener(Self::escape)) + .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_text_size(self.size) + .input_font_size(self.size) .child( div() - .id("dropdown-input") + .id(ElementId::Name(format!("{}-input", self.id).into())) .relative() .flex() .items_center() @@ -623,23 +711,16 @@ where .border_color(cx.theme().border) .rounded(cx.theme().radius) .shadow_sm() - .map(|this| { - if self.disabled { - this.cursor_not_allowed() - } else { - this.cursor_pointer() - } - }) .overflow_hidden() - .input_text_size(self.size) + .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.outline(window, cx)) + .when(outline_visible, |this| this.border_color(cx.theme().ring)) .input_size(self.size) .when(allow_open, |this| { - this.on_click(cx.listener(Self::toggle_menu)) + this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu)) }) .child( h_flex() @@ -651,41 +732,52 @@ where div() .w_full() .overflow_hidden() + .whitespace_nowrap() + .truncate() .child(self.display_title(window, cx)), ) - .map(|this| { + .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 self.open { - IconName::CaretUp + if state.open { + Icon::new(IconName::CaretUp) } else { - IconName::CaretDown + Icon::new(IconName::CaretDown) } } }; - this.child( - Icon::new(icon) - .xsmall() - .text_color(match self.disabled { - true => cx.theme().icon_muted, - false => cx.theme().icon, - }) - .when(self.disabled, |this| this.cursor_not_allowed()), - ) + this.child(icon.xsmall().text_color(match self.disabled { + true => cx.theme().text_placeholder, + false => cx.theme().text_muted, + })) }), ) .child( canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), + { + let state = self.state.clone(); + move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) + }, |_, _, _, _| {}, ) .absolute() .size_full(), ), ) - .when(self.open, |this| { + .when(state.open, |this| { this.child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( @@ -701,17 +793,17 @@ where .mt_1p5() .bg(cx.theme().background) .border_1() - .border_color(cx.theme().border_focused) - .rounded(cx.theme().radius) + .border_color(cx.theme().border) + .rounded(popup_radius) .shadow_md() - .on_mouse_down_out(|_, _, cx| { - cx.dispatch_action(&Escape); - }) - .child(self.list.clone()), + .child(state.list.clone()), ) - .on_mouse_down_out(cx.listener(|this, _, window, cx| { - this.escape(&Escape, window, cx); - })), + .on_mouse_down_out(window.listener_for( + &self.state, + |this, _, window, cx| { + this.escape(&Cancel, window, cx); + }, + )), ), ) .with_priority(1), diff --git a/crates/ui/src/emoji_picker.rs b/crates/ui/src/emoji_picker.rs index ec2098a..9566636 100644 --- a/crates/ui/src/emoji_picker.rs +++ b/crates/ui/src/emoji_picker.rs @@ -10,7 +10,7 @@ use theme::ActiveTheme; use crate::{ button::{Button, ButtonVariants}, - input::TextInput, + input::InputState, popover::{Popover, PopoverContent}, Icon, }; @@ -24,12 +24,12 @@ impl_internal_actions!(emoji, [EmitEmoji]); pub struct EmojiPicker { icon: Option, anchor: Option, - target_input: WeakEntity, + target_input: WeakEntity, emojis: Rc>, } impl EmojiPicker { - pub fn new(target_input: WeakEntity) -> Self { + pub fn new(target_input: WeakEntity) -> Self { let mut emojis: Vec = vec![]; emojis.extend( @@ -102,7 +102,7 @@ impl RenderOnce for EmojiPicker { move |_, window, cx| { if let Some(input) = input.as_ref() { input.update(cx, |this, cx| { - let current = this.text(); + let current = this.value(); let new_text = if current.is_empty() { format!("{}", item) } else if current.ends_with(" ") { @@ -110,7 +110,7 @@ impl RenderOnce for EmojiPicker { } else { format!("{} {}", current, item) }; - this.set_text(new_text, window, cx); + this.set_value(new_text, window, cx); }); } } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index a482b44..972c2e9 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -68,7 +68,6 @@ pub enum IconName { ToggleFill, ThumbsDown, ThumbsUp, - TriangleAlert, Upload, UsersThreeFill, WindowClose, @@ -139,7 +138,6 @@ impl IconName { Self::ToggleFill => "icons/toggle-fill.svg", Self::ThumbsDown => "icons/thumbs-down.svg", Self::ThumbsUp => "icons/thumbs-up.svg", - Self::TriangleAlert => "icons/triangle-alert.svg", Self::Upload => "icons/upload.svg", Self::UsersThreeFill => "icons/users-three-fill.svg", Self::WindowClose => "icons/window-close.svg", diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs index ac4fd43..db1930c 100644 --- a/crates/ui/src/input/blink_cursor.rs +++ b/crates/ui/src/input/blink_cursor.rs @@ -1,6 +1,7 @@ -use gpui::{Context, Timer}; use std::time::Duration; +use gpui::{Context, Timer}; + static INTERVAL: Duration = Duration::from_millis(500); static PAUSE_DELAY: Duration = Duration::from_millis(300); diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs index 4cafb3f..657bbae 100644 --- a/crates/ui/src/input/change.rs +++ b/crates/ui/src/input/change.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, ops::Range}; use crate::history::HistoryItem; -#[derive(Debug, Clone)] +#[derive(Debug, PartialEq, Clone)] pub struct Change { pub(crate) old_range: Range, pub(crate) old_text: String, diff --git a/crates/ui/src/input/clear_button.rs b/crates/ui/src/input/clear_button.rs new file mode 100644 index 0000000..8277636 --- /dev/null +++ b/crates/ui/src/input/clear_button.rs @@ -0,0 +1,16 @@ +use gpui::{App, Styled}; +use theme::ActiveTheme; + +use crate::{ + button::{Button, ButtonVariants as _}, + Icon, IconName, Sizable as _, +}; + +#[inline] +pub(crate) fn clear_button(cx: &App) -> Button { + Button::new("clean") + .icon(Icon::new(IconName::CloseCircle)) + .ghost() + .xsmall() + .text_color(cx.theme().text_muted) +} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index d96f979..5dbf5d8 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -1,24 +1,35 @@ use gpui::{ fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, - Pixels, Point, Style, TextRun, UnderlineStyle, Window, WrappedLine, + Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, }; use smallvec::SmallVec; use theme::ActiveTheme; -use super::TextInput; +use super::InputState; +use crate::Root; const RIGHT_MARGIN: Pixels = px(5.); const BOTTOM_MARGIN: Pixels = px(20.); const CURSOR_THICKNESS: Pixels = px(2.); pub(super) struct TextElement { - input: Entity, + input: Entity, + placeholder: SharedString, } impl TextElement { - pub(super) fn new(input: Entity) -> Self { - Self { input } + pub(super) fn new(input: Entity) -> Self { + Self { + input, + placeholder: SharedString::default(), + } + } + + /// Set the placeholder text of the input field. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = placeholder.into(); + self } fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { @@ -142,7 +153,6 @@ impl TextElement { // cursor blink let cursor_height = window.text_style().font_size.to_pixels(window.rem_size()) + px(4.); - cursor = Some(fill( Bounds::new( point( @@ -301,6 +311,31 @@ impl IntoElement for TextElement { } } +/// A debug function to print points as SVG path. +#[allow(unused)] +fn print_points_as_svg_path(line_corners: &Vec>>, points: &[Point]) { + for corners in line_corners { + println!( + "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", + corners.top_left.x.0 as i32, + corners.top_left.y.0 as i32, + corners.top_right.x.0 as i32, + corners.top_right.y.0 as i32, + corners.bottom_left.x.0 as i32, + corners.bottom_left.y.0 as i32, + corners.bottom_right.x.0 as i32, + corners.bottom_right.y.0 as i32, + ); + } + + if !points.is_empty() { + println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32); + for p in points.iter().skip(1) { + println!("L{},{}", p.x.0 as i32, p.y.0 as i32); + } + } +} + impl Element for TextElement { type RequestLayoutState = (); type PrepaintState = PrepaintState; @@ -319,11 +354,19 @@ impl Element for TextElement { let mut style = Style::default(); style.size.width = relative(1.).into(); if self.input.read(cx).is_multi_line() { - style.size.height = relative(1.).into(); - style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into(); + style.flex_grow = 1.0; + if let Some(h) = input.height { + style.size.height = h.into(); + style.min_size.height = window.line_height().into(); + } else { + style.size.height = relative(1.).into(); + style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into(); + } } else { + // For single-line inputs, the minimum height should be the line height style.size.height = window.line_height().into(); }; + (window.request_layout(style, [], cx), ()) } @@ -339,7 +382,7 @@ impl Element for TextElement { let line_height = window.line_height(); let input = self.input.read(cx); let text = input.text.clone(); - let placeholder = input.placeholder.clone(); + let placeholder = self.placeholder.clone(); let style = window.text_style(); let mut bounds = bounds; @@ -388,7 +431,6 @@ impl Element for TextElement { }; let font_size = style.font_size.to_pixels(window.rem_size()); - let wrap_width = if multi_line { Some(bounds.size.width - RIGHT_MARGIN) } else { @@ -465,6 +507,32 @@ impl Element for TextElement { cx, ); + // Set Root focused_input when self is focused + if focused { + let state = self.input.clone(); + + if Root::read(window, cx).focused_input.as_ref() != Some(&state) { + Root::update(window, cx, |root, _, cx| { + root.focused_input = Some(state); + cx.notify(); + }); + } + } + + // And reset focused_input when next_frame start + window.on_next_frame({ + let state = self.input.clone(); + + move |window, cx| { + if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) { + Root::update(window, cx, |root, _, cx| { + root.focused_input = None; + cx.notify(); + }); + } + } + }); + // Paint selections if let Some(path) = prepaint.selection_path.take() { window.paint_path(path, cx.theme().element_disabled); @@ -475,7 +543,6 @@ impl Element for TextElement { let origin = bounds.origin; let mut offset_y = px(0.); - if self.input.read(cx).masked { // Move down offset for vertical centering the ***** if cfg!(target_os = "macos") { @@ -484,10 +551,9 @@ impl Element for TextElement { offset_y = px(2.5); } } - for line in prepaint.lines.iter() { let p = point(origin.x, origin.y + offset_y); - _ = line.paint(p, line_height, gpui::TextAlign::Left, None, window, cx); + _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line.size(line_height).height; } diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs new file mode 100644 index 0000000..9ea6737 --- /dev/null +++ b/crates/ui/src/input/mask_pattern.rs @@ -0,0 +1,380 @@ +use gpui::SharedString; + +#[derive(Clone, PartialEq, Debug)] +pub enum MaskToken { + /// 0 Digit, equivalent to `[0]` + // Digit0, + /// Digit, equivalent to `[0-9]` + Digit, + /// Letter, equivalent to `[a-zA-Z]` + Letter, + /// Letter or digit, equivalent to `[a-zA-Z0-9]` + LetterOrDigit, + /// Separator + Sep(char), + /// Any character + Any, +} + +#[allow(unused)] +impl MaskToken { + /// Check if the token is any character. + pub fn is_any(&self) -> bool { + matches!(self, MaskToken::Any) + } + + /// Check if the token is a match for the given character. + /// + /// The separator is always a match any input character. + fn is_match(&self, ch: char) -> bool { + match self { + MaskToken::Digit => ch.is_ascii_digit(), + MaskToken::Letter => ch.is_ascii_alphabetic(), + MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(), + MaskToken::Any => true, + MaskToken::Sep(c) => *c == ch, + } + } + + /// Is the token a separator (Can be ignored) + fn is_sep(&self) -> bool { + matches!(self, MaskToken::Sep(_)) + } + + /// Check if the token is a number. + pub fn is_number(&self) -> bool { + matches!(self, MaskToken::Digit) + } + + pub fn placeholder(&self) -> char { + match self { + MaskToken::Sep(c) => *c, + _ => '_', + } + } + + fn mask_char(&self, ch: char) -> char { + match self { + MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch, + MaskToken::Sep(c) => *c, + MaskToken::Any => ch, + } + } + + fn unmask_char(&self, ch: char) -> Option { + match self { + MaskToken::Digit => Some(ch), + MaskToken::Letter => Some(ch), + MaskToken::LetterOrDigit => Some(ch), + MaskToken::Any => Some(ch), + _ => None, + } + } +} + +#[derive(Clone, Default)] +pub enum MaskPattern { + #[default] + None, + Pattern { + pattern: SharedString, + tokens: Vec, + }, + Number { + /// Group separator, e.g. "," or " " + separator: Option, + /// Number of fraction digits, e.g. 2 for 123.45 + fraction: Option, + }, +} + +impl From<&str> for MaskPattern { + fn from(pattern: &str) -> Self { + Self::new(pattern) + } +} + +impl MaskPattern { + /// Create a new mask pattern + /// + /// - `9` - Digit + /// - `A` - Letter + /// - `#` - Letter or Digit + /// - `*` - Any character + /// - other characters - Separator + /// + /// For example: + /// + /// - `(999)999-9999` - US phone number: (123)456-7890 + /// - `99999-9999` - ZIP code: 12345-6789 + /// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4 + /// - `*999*` - Custom pattern: (123) or [123] + pub fn new(pattern: &str) -> Self { + let tokens = pattern + .chars() + .map(|ch| match ch { + // '0' => MaskToken::Digit0, + '9' => MaskToken::Digit, + 'A' => MaskToken::Letter, + '#' => MaskToken::LetterOrDigit, + '*' => MaskToken::Any, + _ => MaskToken::Sep(ch), + }) + .collect(); + + Self::Pattern { + pattern: pattern.to_owned().into(), + tokens, + } + } + + #[allow(unused)] + fn tokens(&self) -> Option<&Vec> { + match self { + Self::Pattern { tokens, .. } => Some(tokens), + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Create a new mask pattern with group separator, e.g. "," or " " + pub fn number(sep: Option) -> Self { + Self::Number { + separator: sep, + fraction: None, + } + } + + pub fn placeholder(&self) -> Option { + match self { + Self::Pattern { tokens, .. } => { + Some(tokens.iter().map(|token| token.placeholder()).collect()) + } + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Return true if the mask pattern is None or no any pattern. + pub fn is_none(&self) -> bool { + match self { + Self::Pattern { tokens, .. } => tokens.is_empty(), + Self::Number { .. } => false, + Self::None => true, + } + } + + /// Check is the mask text is valid. + /// + /// If the mask pattern is None, always return true. + pub fn is_valid(&self, mask_text: &str) -> bool { + if self.is_none() { + return true; + } + + let mut text_index = 0; + let mask_text_chars: Vec = mask_text.chars().collect(); + match self { + Self::Pattern { tokens, .. } => { + for token in tokens { + if text_index >= mask_text_chars.len() { + break; + } + + let ch = mask_text_chars[text_index]; + if token.is_match(ch) { + text_index += 1; + } + } + text_index == mask_text.len() + } + Self::Number { separator, .. } => { + if mask_text.is_empty() { + return true; + } + + // check if the text is valid number + let mut parts = mask_text.split('.'); + let int_part = parts.next().unwrap_or(""); + let frac_part = parts.next(); + + if int_part.is_empty() { + return false; + } + + // check if the integer part is valid + if !int_part + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + + // check if the fraction part is valid + if let Some(frac) = frac_part { + if !frac + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + } + + true + } + Self::None => true, + } + } + + /// Check if valid input char at the given position. + pub fn is_valid_at(&self, ch: char, pos: usize) -> bool { + if self.is_none() { + return true; + } + + match self { + Self::Pattern { tokens, .. } => { + if let Some(token) = tokens.get(pos) { + if token.is_match(ch) { + return true; + } + + if token.is_sep() { + // If next token is match, it's valid + if let Some(next_token) = tokens.get(pos + 1) { + if next_token.is_match(ch) { + return true; + } + } + } + } + + false + } + Self::Number { .. } => true, + Self::None => true, + } + } + + /// Format the text according to the mask pattern + /// + /// For example: + /// + /// - pattern: (999)999-999 + /// - text: 123456789 + /// - mask_text: (123)456-789 + pub fn mask(&self, text: &str) -> SharedString { + if self.is_none() { + return text.to_owned().into(); + } + + match self { + Self::Number { + separator, + fraction, + } => { + if let Some(sep) = *separator { + // Remove the existing group separator + let text = text.replace(sep, ""); + + let mut parts = text.split('.'); + let int_part = parts.next().unwrap_or(""); + + // Limit the fraction part to the given range, if not enough, pad with 0 + let frac_part = parts.next().map(|part| { + part.chars() + .take(fraction.unwrap_or(usize::MAX)) + .collect::() + }); + + // Reverse the integer part for easier grouping + let chars: Vec = int_part.chars().rev().collect(); + let mut result = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(sep); + } + result.push(*ch); + } + let int_with_sep: String = result.chars().rev().collect(); + + let final_str = if let Some(frac) = frac_part { + if fraction == &Some(0) { + int_with_sep + } else { + format!("{}.{}", int_with_sep, frac) + } + } else { + int_with_sep + }; + return final_str.into(); + } + + text.to_owned().into() + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mut text_index = 0; + let text_chars: Vec = text.chars().collect(); + for (pos, token) in tokens.iter().enumerate() { + if text_index >= text_chars.len() { + break; + } + let ch = text_chars[text_index]; + // Break if expected char is not match + if !token.is_sep() && !self.is_valid_at(ch, pos) { + break; + } + let mask_ch = token.mask_char(ch); + result.push(mask_ch); + if ch == mask_ch { + text_index += 1; + continue; + } + } + result.into() + } + Self::None => text.to_owned().into(), + } + } + + /// Extract original text from masked text + pub fn unmask(&self, mask_text: &str) -> String { + match self { + Self::Number { separator, .. } => { + if let Some(sep) = *separator { + let mut result = String::new(); + for ch in mask_text.chars() { + if ch == sep { + continue; + } + result.push(ch); + } + + if result.contains('.') { + result = result.trim_end_matches('0').to_string(); + } + return result; + } + + mask_text.to_owned() + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mask_text_chars: Vec = mask_text.chars().collect(); + for (text_index, token) in tokens.iter().enumerate() { + if text_index >= mask_text_chars.len() { + break; + } + let ch = mask_text_chars[text_index]; + let unmask_ch = token.unmask_char(ch); + if let Some(ch) = unmask_ch { + result.push(ch); + } + } + result + } + Self::None => mask_text.to_owned(), + } + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index c9a0005..1106447 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,7 +1,12 @@ mod blink_cursor; mod change; mod element; -#[allow(clippy::module_inception)] -mod input; +mod mask_pattern; +mod state; +mod text_input; -pub use input::*; +pub(crate) mod clear_button; + +#[allow(ambiguous_glob_reexports)] +pub use state::*; +pub use text_input::*; diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/state.rs similarity index 76% rename from crates/ui/src/input/input.rs rename to crates/ui/src/input/state.rs index eea560c..71d2358 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/state.rs @@ -1,24 +1,33 @@ -use std::{cell::Cell, ops::Range, rc::Rc}; - -use gpui::{ - actions, div, point, prelude::FluentBuilder as _, px, AnyElement, App, AppContext, Bounds, - ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, - InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Rems, Render, ScrollHandle, - ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, WrappedLine, -}; +use serde::Deserialize; use smallvec::SmallVec; -use theme::ActiveTheme; +use std::{cell::Cell, ops::Range, rc::Rc}; use unicode_segmentation::*; -use super::{blink_cursor::BlinkCursor, change::Change, element::TextElement}; -use crate::{ - history::History, - indicator::Indicator, - scroll::{Scrollbar, ScrollbarAxis, ScrollbarState}, - Sizable, Size, StyleSized, StyledExt, +use gpui::{ + actions, div, impl_internal_actions, point, prelude::FluentBuilder as _, px, App, AppContext, + Bounds, ClipboardItem, Context, DefiniteLength, Entity, EntityInputHandler, EventEmitter, + FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, + Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, + UTF16Selection, Window, WrappedLine, }; +// TODO: +// - Move cursor to skip line eof empty chars. + +use super::{ + blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern, +}; +use crate::{history::History, scroll::ScrollbarState, Root}; + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub struct Enter { + /// Is confirm with secondary. + pub secondary: bool, +} + +impl_internal_actions!(input, [Enter]); + actions!( input, [ @@ -28,7 +37,6 @@ actions!( DeleteToEndOfLine, DeleteToPreviousWordStart, DeleteToNextWordEnd, - Enter, Up, Down, Left, @@ -52,6 +60,7 @@ actions!( Paste, Undo, Redo, + NewLine, MoveToStartOfLine, MoveToEndOfLine, MoveToStart, @@ -59,18 +68,19 @@ actions!( MoveToPreviousWord, MoveToNextWord, TextChanged, + Escape ] ); #[derive(Clone)] pub enum InputEvent { Change(SharedString), - PressEnter, + PressEnter { secondary: bool }, Focus, Blur, } -const CONTEXT: &str = "Input"; +pub(super) const CONTEXT: &str = "Input"; pub fn init(cx: &mut App) { cx.bind_keys([ @@ -88,7 +98,9 @@ pub fn init(cx: &mut App) { KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)), - KeyBinding::new("enter", Enter, Some(CONTEXT)), + KeyBinding::new("enter", Enter { secondary: false }, Some(CONTEXT)), + KeyBinding::new("secondary-enter", Enter { secondary: true }, Some(CONTEXT)), + KeyBinding::new("escape", Escape, Some(CONTEXT)), KeyBinding::new("up", Up, Some(CONTEXT)), KeyBinding::new("down", Down, Some(CONTEXT)), KeyBinding::new("left", Left, Some(CONTEXT)), @@ -101,6 +113,7 @@ pub fn init(cx: &mut App) { KeyBinding::new("end", End, Some(CONTEXT)), KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), + KeyBinding::new("shift-enter", NewLine, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), #[cfg(target_os = "macos")] @@ -170,20 +183,21 @@ pub fn init(cx: &mut App) { ]); } -type TextInputPrefix = Option) -> AnyElement + 'static>>; -type TextInputSuffix = Option) -> AnyElement + 'static>>; type Validate = Option bool + 'static>>; -pub struct TextInput { +/// InputState to keep editing state of the [`super::TextInput`]. +pub struct InputState { pub(super) focus_handle: FocusHandle, pub(super) text: SharedString, - multi_line: bool, + pub(super) multi_line: bool, + pub(super) new_line_on_enter: bool, pub(super) history: History, pub(super) blink_cursor: Entity, - pub(super) prefix: TextInputPrefix, - pub(super) suffix: TextInputSuffix, pub(super) loading: bool, - pub(super) placeholder: SharedString, + /// Range in UTF-8 length for the selected text. + /// + /// - "Hello 世界💝" = 16 + /// - "💝" = 4 pub(super) selected_range: Range, /// Range for save the selected word, use to keep word range when drag move. pub(super) selected_word_range: Option>, @@ -198,34 +212,39 @@ pub struct TextInput { /// The text bounds pub(super) last_bounds: Option>, pub(super) last_selected_range: Option>, - pub(super) is_selecting: bool, + pub(super) selecting: bool, pub(super) disabled: bool, pub(super) masked: bool, - pub(super) appearance: bool, - pub(super) cleanable: bool, - pub(super) size: Size, - pub(super) text_size: Size, + pub(super) clean_on_escape: bool, + pub(super) height: Option, pub(super) rows: usize, - pattern: Option, - validate: Validate, + pub(super) min_rows: usize, + pub(super) max_rows: Option, + pub(super) pattern: Option, + pub(super) validate: Validate, pub(crate) scroll_handle: ScrollHandle, - scrollbar_state: Rc>, + pub(super) scrollbar_state: Rc>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, + + /// The mask pattern for formatting the input text + pub(crate) mask_pattern: MaskPattern, + pub(super) placeholder: SharedString, + /// To remember the horizontal column (x-coordinate) of the cursor position. preferred_x_offset: Option, - #[allow(dead_code)] - subscriptions: Vec, + _subscriptions: Vec, } -impl EventEmitter for TextInput {} +impl EventEmitter for InputState {} -impl TextInput { +impl InputState { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new(|_| BlinkCursor::new()); let history = History::new().group_interval(std::time::Duration::from_secs(1)); - let subscriptions = vec![ + + let _subscriptions = vec![ // Observe the blink cursor to repaint the view when it changes. cx.observe(&blink_cursor, |_, _, cx| cx.notify()), // Blink the cursor when the window is active, pause when it's not. @@ -247,37 +266,37 @@ impl TextInput { focus_handle: focus_handle.clone(), text: "".into(), multi_line: false, + new_line_on_enter: true, blink_cursor, history, - placeholder: "".into(), selected_range: 0..0, selected_word_range: None, selection_reversed: false, marked_range: None, input_bounds: Bounds::default(), - is_selecting: false, + selecting: false, disabled: false, masked: false, - appearance: true, - cleanable: false, + clean_on_escape: false, loading: false, - prefix: None, - suffix: None, - size: Size::Medium, - text_size: Size::Medium, pattern: None, validate: None, - rows: 2, + rows: 3, + min_rows: 3, + max_rows: None, + height: None, last_layout: None, last_bounds: None, last_selected_range: None, - last_line_height: px(20.), + last_line_height: px(19.), last_cursor_offset: None, scroll_handle: ScrollHandle::new(), scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), scroll_size: gpui::size(px(0.), px(0.)), preferred_x_offset: None, - subscriptions, + placeholder: SharedString::default(), + mask_pattern: MaskPattern::default(), + _subscriptions, } } @@ -287,6 +306,29 @@ impl TextInput { self } + /// Disables new lines on enter when multi-line is enabled. + pub fn prevent_new_line_on_enter(mut self) -> Self { + self.new_line_on_enter = false; + self + } + + /// Set placeholder + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = placeholder.into(); + self + } + + /// Set placeholder + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + /// Called after moving the cursor. Updates preferred_x_offset if we know where the cursor now is. fn update_preferred_x_offset(&mut self, _cx: &mut Context) { if let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) { @@ -431,25 +473,67 @@ impl TextInput { /// default: 2 pub fn rows(mut self, rows: usize) -> Self { self.rows = rows; + self.min_rows = rows; + self + } + + /// Set the maximum number of rows for the multi-line Textarea. + /// + /// If max_rows is more than rows, then will enable auto-grow. + /// + /// This is only used when `multi_line` is set to true. + /// + /// default: None + pub fn max_rows(mut self, max_rows: usize) -> Self { + self.max_rows = Some(max_rows); self } /// Set the text of the input field. /// /// And the selection_range will be reset to 0..0. - pub fn set_text( + pub fn set_value( + &mut self, + value: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + self.history.ignore = true; + self.replace_text(value, window, cx); + self.history.ignore = false; + // Ensure cursor to start when set text + self.selected_range = self.text.len()..self.text.len(); + + cx.notify(); + } + + /// Insert text at the current cursor position. + /// + /// And the cursor will be moved to the end of inserted text. + pub fn insert( &mut self, text: impl Into, window: &mut Window, cx: &mut Context, ) { - self.history.ignore = true; - self.replace_text(text, window, cx); - self.history.ignore = false; - // Ensure cursor to start when set text - self.selected_range = 0..0; + let text: SharedString = text.into(); + let range = self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset())); + self.replace_text_in_range(Some(range), &text, window, cx); + self.selected_range = self.selected_range.end..self.selected_range.end; + } - cx.notify(); + /// Replace text at the current cursor position. + /// + /// And the cursor will be moved to the end of replaced text. + pub fn replace( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let text: SharedString = text.into(); + self.replace_text_in_range(None, &text, window, cx); + self.selected_range = self.selected_range.end..self.selected_range.end; } fn replace_text( @@ -469,92 +553,21 @@ impl TextInput { cx.notify(); } - /// Set the masked state of the input field. - pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { + /// Set with password masked state. + pub fn masked(mut self, masked: bool) -> Self { + self.masked = masked; + self + } + + /// Set the password masked state of the input field. + pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { self.masked = masked; cx.notify(); } - /// Set the prefix element of the input field. - pub fn set_prefix(&mut self, builder: F, _: &mut Window, cx: &mut Context) - where - F: Fn(&Window, &Context) -> E + 'static, - E: IntoElement, - { - self.prefix = Some(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })); - cx.notify(); - } - - /// Set the suffix element of the input field. - pub fn set_suffix(&mut self, builder: F, _: &mut Window, cx: &mut Context) - where - F: Fn(&Window, &Context) -> E + 'static, - E: IntoElement, - { - self.suffix = Some(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })); - cx.notify(); - } - - /// Set the Input size - pub fn set_size(&mut self, size: Size, _window: &mut Window, cx: &mut Context) { - self.size = size; - cx.notify(); - } - - /// Set the Input size - pub fn text_size(mut self, size: Size) -> Self { - self.text_size = size; - self - } - - /// Set the appearance of the input field. - pub fn appearance(mut self, appearance: bool) -> Self { - self.appearance = appearance; - self - } - - /// Set the prefix element of the input field, for example a search Icon. - pub fn prefix(mut self, builder: F) -> Self - where - F: Fn(&mut Window, &mut Context) -> E + 'static, - E: IntoElement, - { - self.prefix = Some(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })); - self - } - - /// Set the suffix element of the input field, for example a clear button. - pub fn suffix(mut self, builder: F) -> Self - where - F: Fn(&mut Window, &mut Context) -> E + 'static, - E: IntoElement, - { - self.suffix = Some(Box::new(move |window, cx| { - builder(window, cx).into_any_element() - })); - self - } - - /// Set the placeholder text of the input field. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = placeholder.into(); - self - } - - /// Set the placeholder text of the input field with reference. - pub fn set_placeholder(&mut self, placeholder: impl Into) { - self.placeholder = placeholder.into(); - } - - /// Set true to show the clear button when the input field is not empty. - pub fn cleanable(mut self) -> Self { - self.cleanable = true; + /// Set true to clear the input by pressing Escape key. + pub fn clean_on_escape(mut self) -> Self { + self.clean_on_escape = true; self } @@ -565,7 +578,12 @@ impl TextInput { } /// Set the regular expression pattern of the input field with reference. - pub fn set_pattern(&mut self, pattern: regex::Regex) { + pub fn set_pattern( + &mut self, + pattern: regex::Regex, + _window: &mut Window, + _cx: &mut Context, + ) { self.pattern = Some(pattern); } @@ -581,9 +599,20 @@ impl TextInput { cx.notify(); } - /// Return the text of the input field. - pub fn text(&self) -> SharedString { - self.text.clone() + /// Set the default value of the input field. + pub fn default_value(mut self, value: impl Into) -> Self { + self.text = value.into(); + self + } + + /// Return the value of the input field. + pub fn value(&self) -> &SharedString { + &self.text + } + + /// Return the value without mask. + pub fn unmask_value(&self) -> SharedString { + self.mask_pattern.unmask(&self.text).into() } pub fn disabled(&self) -> bool { @@ -591,11 +620,11 @@ impl TextInput { } /// Focus the input field. - pub fn focus(&self, window: &mut Window, _cx: &mut Context) { + pub fn focus(&self, window: &mut Window, _: &mut Context) { self.focus_handle.focus(window); } - fn left(&mut self, _: &Left, window: &mut Window, cx: &mut Context) { + pub(super) fn left(&mut self, _: &Left, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { self.move_to(self.previous_boundary(self.cursor_offset()), window, cx); @@ -604,7 +633,7 @@ impl TextInput { } } - fn right(&mut self, _: &Right, window: &mut Window, cx: &mut Context) { + pub(super) fn right(&mut self, _: &Right, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { self.move_to(self.next_boundary(self.selected_range.end), window, cx); @@ -613,7 +642,7 @@ impl TextInput { } } - fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { + pub(super) fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { if self.is_single_line() { return; } @@ -621,7 +650,7 @@ impl TextInput { self.move_vertical(-1, window, cx); } - fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { + pub(super) fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { if self.is_single_line() { return; } @@ -629,15 +658,25 @@ impl TextInput { self.move_vertical(1, window, cx); } - fn select_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + pub(super) fn select_left( + &mut self, + _: &SelectLeft, + window: &mut Window, + cx: &mut Context, + ) { self.select_to(self.previous_boundary(self.cursor_offset()), window, cx); } - fn select_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + pub(super) fn select_right( + &mut self, + _: &SelectRight, + window: &mut Window, + cx: &mut Context, + ) { self.select_to(self.next_boundary(self.cursor_offset()), window, cx); } - fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + pub(super) fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { if self.is_single_line() { return; } @@ -645,7 +684,12 @@ impl TextInput { self.select_to(offset, window, cx); } - fn select_down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { + pub(super) fn select_down( + &mut self, + _: &SelectDown, + window: &mut Window, + cx: &mut Context, + ) { if self.is_single_line() { return; } @@ -653,33 +697,67 @@ impl TextInput { self.select_to(self.next_boundary(offset), window, cx); } - fn select_all(&mut self, _: &SelectAll, window: &mut Window, cx: &mut Context) { + pub(super) fn select_all( + &mut self, + _: &SelectAll, + window: &mut Window, + cx: &mut Context, + ) { self.move_to(0, window, cx); self.select_to(self.text.len(), window, cx) } - fn home(&mut self, _: &Home, window: &mut Window, cx: &mut Context) { + pub(super) fn home(&mut self, _: &Home, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.start_of_line(window, cx); self.move_to(offset, window, cx); } - fn end(&mut self, _: &End, window: &mut Window, cx: &mut Context) { + pub(super) fn end(&mut self, _: &End, window: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); let offset = self.end_of_line(window, cx); self.move_to(offset, window, cx); } - fn move_to_start(&mut self, _: &MoveToStart, window: &mut Window, cx: &mut Context) { + pub(super) fn shift_to_new_line( + &mut self, + _: &NewLine, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_multi_line() { + let is_eof = self.selected_range.end == self.text.len(); + self.replace_text_in_range(None, "\n", window, cx); + + // Move cursor to the start of the next line + let mut new_offset = self.next_boundary(self.cursor_offset()) - 1; + if is_eof { + new_offset += 1; + } + self.move_to(new_offset, window, cx); + } + } + + pub(super) fn move_to_start( + &mut self, + _: &MoveToStart, + window: &mut Window, + cx: &mut Context, + ) { self.move_to(0, window, cx); } - fn move_to_end(&mut self, _: &MoveToEnd, window: &mut Window, cx: &mut Context) { + pub(super) fn move_to_end( + &mut self, + _: &MoveToEnd, + window: &mut Window, + cx: &mut Context, + ) { let end = self.text.len(); self.move_to(end, window, cx); } - fn move_to_previous_word( + pub(super) fn move_to_previous_word( &mut self, _: &MoveToPreviousWord, window: &mut Window, @@ -688,7 +766,8 @@ impl TextInput { let offset = self.previous_start_of_word(); self.move_to(offset, window, cx); } - fn move_to_next_word( + + pub(super) fn move_to_next_word( &mut self, _: &MoveToNextWord, window: &mut Window, @@ -698,16 +777,26 @@ impl TextInput { self.move_to(offset, window, cx); } - fn select_to_start(&mut self, _: &SelectToStart, window: &mut Window, cx: &mut Context) { + pub(super) fn select_to_start( + &mut self, + _: &SelectToStart, + window: &mut Window, + cx: &mut Context, + ) { self.select_to(0, window, cx); } - fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { + pub(super) fn select_to_end( + &mut self, + _: &SelectToEnd, + window: &mut Window, + cx: &mut Context, + ) { let end = self.text.len(); self.select_to(end, window, cx); } - fn select_to_start_of_line( + pub(super) fn select_to_start_of_line( &mut self, _: &SelectToStartOfLine, window: &mut Window, @@ -717,7 +806,7 @@ impl TextInput { self.select_to(self.previous_boundary(offset), window, cx); } - fn select_to_end_of_line( + pub(super) fn select_to_end_of_line( &mut self, _: &SelectToEndOfLine, window: &mut Window, @@ -727,7 +816,7 @@ impl TextInput { self.select_to(self.next_boundary(offset), window, cx); } - fn select_to_previous_word( + pub(super) fn select_to_previous_word( &mut self, _: &SelectToPreviousWordStart, window: &mut Window, @@ -737,7 +826,7 @@ impl TextInput { self.select_to(offset, window, cx); } - fn select_to_next_word( + pub(super) fn select_to_next_word( &mut self, _: &SelectToNextWordEnd, window: &mut Window, @@ -816,7 +905,7 @@ impl TextInput { .unwrap_or(self.text.len()) } - fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { self.select_to(self.previous_boundary(self.cursor_offset()), window, cx) } @@ -824,7 +913,7 @@ impl TextInput { self.pause_blink_cursor(cx); } - fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { self.select_to(self.next_boundary(self.cursor_offset()), window, cx) } @@ -832,29 +921,36 @@ impl TextInput { self.pause_blink_cursor(cx); } - fn delete_to_beginning_of_line( + pub(super) fn delete_to_beginning_of_line( &mut self, _: &DeleteToBeginningOfLine, window: &mut Window, cx: &mut Context, ) { - let offset = self.start_of_line(window, cx); + let mut offset = self.start_of_line(window, cx); + if offset == self.cursor_offset() { + offset = offset.saturating_sub(1); + } self.replace_text_in_range( Some(self.range_to_utf16(&(offset..self.cursor_offset()))), "", window, cx, ); + self.pause_blink_cursor(cx); } - fn delete_to_end_of_line( + pub(super) fn delete_to_end_of_line( &mut self, _: &DeleteToEndOfLine, window: &mut Window, cx: &mut Context, ) { - let offset = self.end_of_line(window, cx); + let mut offset = self.end_of_line(window, cx); + if offset == self.cursor_offset() { + offset = (offset + 1).clamp(0, self.text.len()); + } self.replace_text_in_range( Some(self.range_to_utf16(&(self.cursor_offset()..offset))), "", @@ -864,7 +960,7 @@ impl TextInput { self.pause_blink_cursor(cx); } - fn delete_previous_word( + pub(super) fn delete_previous_word( &mut self, _: &DeleteToPreviousWordStart, window: &mut Window, @@ -880,7 +976,7 @@ impl TextInput { self.pause_blink_cursor(cx); } - fn delete_next_word( + pub(super) fn delete_next_word( &mut self, _: &DeleteToNextWordEnd, window: &mut Window, @@ -896,8 +992,8 @@ impl TextInput { self.pause_blink_cursor(cx); } - fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context) { - if self.is_multi_line() { + pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { + if self.is_multi_line() && self.new_line_on_enter { let is_eof = self.selected_range.end == self.text.len(); self.replace_text_in_range(None, "\n", window, cx); @@ -909,16 +1005,53 @@ impl TextInput { self.move_to(new_offset, window, cx); } - cx.emit(InputEvent::PressEnter); + cx.emit(InputEvent::PressEnter { + secondary: action.secondary, + }); } - fn on_mouse_down( + fn check_to_auto_grow(&mut self, _: &mut Window, cx: &mut Context) { + if !self.is_multi_line() { + return; + } + + let Some(max_rows) = self.max_rows else { + return; + }; + + let changed_rows = ((self.scroll_size.height - self.input_bounds.size.height) + / self.last_line_height) as isize; + + self.rows = (self.rows as isize + changed_rows) + .clamp(self.min_rows as isize, max_rows as isize) + .max(0) as usize; + + cx.notify(); + } + + pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { + self.replace_text("", window, cx); + } + + pub(super) fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + return self.unselect(window, cx); + } + + if self.clean_on_escape { + return self.clean(window, cx); + } + + cx.propagate(); + } + + pub(super) fn on_mouse_down( &mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { - self.is_selecting = true; + self.selecting = true; let offset = self.index_for_mouse_position(event.position, window, cx); // Double click to select word if event.button == MouseButton::Left && event.click_count == 2 { @@ -933,31 +1066,41 @@ impl TextInput { } } - fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _cx: &mut Context) { - self.is_selecting = false; - self.selected_word_range = None; - } - - fn on_scroll_wheel( + pub(super) fn on_mouse_up( &mut self, - event: &ScrollWheelEvent, + _: &MouseUpEvent, _window: &mut Window, _cx: &mut Context, + ) { + self.selecting = false; + self.selected_word_range = None; + } + + pub(super) fn on_scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + _window: &mut Window, + cx: &mut Context, ) { let delta = event.delta.pixel_delta(self.last_line_height); + self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx); + } + + fn update_scroll_offset(&mut self, offset: Option>, cx: &mut Context) { + let mut offset = offset.unwrap_or(self.scroll_handle.offset()); + let safe_y_range = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width).min(px(0.0))..px(0.); - let mut offset = self.scroll_handle.offset() + delta; offset.y = offset.y.clamp(safe_y_range.start, safe_y_range.end); offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); - self.scroll_handle.set_offset(offset); + cx.notify(); } - fn show_character_palette( + pub(super) fn show_character_palette( &mut self, _: &ShowCharacterPalette, window: &mut Window, @@ -966,7 +1109,7 @@ impl TextInput { window.show_character_palette(); } - fn copy(&mut self, _: &Copy, _window: &mut Window, cx: &mut Context) { + pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { return; } @@ -975,18 +1118,17 @@ impl TextInput { cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } - fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { return; } - let range = self.range_from_utf16(&self.selected_range); - let selected_text = self.text[range].to_string(); + let selected_text = self.text[self.selected_range.clone()].to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range(None, "", window, cx); } - fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { if let Some(clipboard) = cx.read_from_clipboard() { let mut new_text = clipboard.text().unwrap_or_default(); if !self.multi_line { @@ -1022,7 +1164,7 @@ impl TextInput { )); } - fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { + pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { self.history.ignore = true; if let Some(changes) = self.history.undo() { for change in changes { @@ -1033,7 +1175,7 @@ impl TextInput { self.history.ignore = false; } - fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { + pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { self.history.ignore = true; if let Some(changes) = self.history.redo() { for change in changes { @@ -1050,6 +1192,7 @@ impl TextInput { /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let offset = offset.clamp(0, self.text.len()); self.selected_range = offset..offset; self.pause_blink_cursor(cx); self.update_preferred_x_offset(cx); @@ -1171,6 +1314,7 @@ impl TextInput { /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. fn select_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let offset = offset.clamp(0, self.text.len()); if self.selection_reversed { self.selected_range.start = offset } else { @@ -1198,6 +1342,8 @@ impl TextInput { } /// Select the word at the given offset. + /// + /// The offset is the UTF-8 offset. fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context) { fn is_word(c: char) -> bool { c.is_alphanumeric() || matches!(c, '_') @@ -1236,8 +1382,9 @@ impl TextInput { cx.notify() } - fn unselect(&mut self, _window: &mut Window, cx: &mut Context) { - self.selected_range = self.cursor_offset()..self.cursor_offset(); + fn unselect(&mut self, _: &mut Window, cx: &mut Context) { + let offset = self.next_boundary(self.cursor_offset()); + self.selected_range = offset..offset; cx.notify() } @@ -1299,7 +1446,7 @@ impl TextInput { self.focus_handle.is_focused(window) && self.blink_cursor.read(cx).visible() } - fn on_focus(&mut self, _window: &mut Window, cx: &mut Context) { + fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { self.blink_cursor.update(cx, |cursor, cx| { cursor.start(cx); }); @@ -1311,6 +1458,9 @@ impl TextInput { self.blink_cursor.update(cx, |cursor, cx| { cursor.stop(cx); }); + Root::update(window, cx, |root, _, _| { + root.focused_input = None; + }); cx.emit(InputEvent::Blur); } @@ -1320,13 +1470,8 @@ impl TextInput { }); } - fn on_key_down_for_blink_cursor( - &mut self, - _: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - self.pause_blink_cursor(cx) + pub(super) fn on_key_down(&mut self, _: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); } pub(super) fn on_drag_move( @@ -1347,7 +1492,7 @@ impl TextInput { return; } - if !self.is_selecting { + if !self.selecting { return; } @@ -1366,21 +1511,48 @@ impl TextInput { } } + if !self.mask_pattern.is_valid(new_text) { + return false; + } + self.pattern .as_ref() .map(|p| p.is_match(new_text)) .unwrap_or(true) } -} -impl Sizable for TextInput { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); + /// Set the mask pattern for formatting the input text. + /// + /// The pattern can contain: + /// - 9: Any digit or dot + /// - A: Any letter + /// - *: Any character + /// - Other characters will be treated as literal mask characters + /// + /// Example: "(999)999-999" for phone numbers + pub fn mask_pattern(mut self, pattern: impl Into) -> Self { + self.mask_pattern = pattern.into(); + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } self } + + pub fn set_mask_pattern( + &mut self, + pattern: impl Into, + _: &mut Window, + cx: &mut Context, + ) { + self.mask_pattern = pattern.into(); + if let Some(placeholder) = self.mask_pattern.placeholder() { + self.placeholder = placeholder.into(); + } + cx.notify(); + } } -impl EntityInputHandler for TextInput { +impl EntityInputHandler for InputState { fn text_for_range( &mut self, range_utf16: Range, @@ -1438,16 +1610,27 @@ impl EntityInputHandler for TextInput { let pending_text: SharedString = (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); + + // Check if the new text is valid if !self.is_valid_input(&pending_text) { return; } + let mask_text = self.mask_pattern.mask(&pending_text); + let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); + let new_pos = (range.start + new_text_len).min(mask_text.len()); + self.push_history(&range, new_text, window, cx); - self.text = pending_text; - self.selected_range = range.start + new_text.len()..range.start + new_text.len(); + + self.text = mask_text; + self.selected_range = new_pos..new_pos; self.marked_range.take(); + self.update_preferred_x_offset(cx); - cx.emit(InputEvent::Change(self.text.clone())); + self.update_scroll_offset(None, cx); + self.check_to_auto_grow(window, cx); + + cx.emit(InputEvent::Change(self.unmask_value())); cx.notify(); } @@ -1482,7 +1665,7 @@ impl EntityInputHandler for TextInput { .map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|new_range| new_range.start + range.start..new_range.end + range.end) .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); - cx.emit(InputEvent::Change(self.text.clone())); + cx.emit(InputEvent::Change(self.unmask_value())); cx.notify(); } @@ -1510,7 +1693,6 @@ impl EntityInputHandler for TextInput { { start_origin = Some(p + point(px(0.), y_offset)); } - if let Some(p) = line.position_for_index(range.end.saturating_sub(index_offset), line_height) { @@ -1518,7 +1700,6 @@ impl EntityInputHandler for TextInput { } y_offset += line.size(line_height).height; - if start_origin.is_some() && end_origin.is_some() { break; } @@ -1545,131 +1726,31 @@ impl EntityInputHandler for TextInput { let line_height = self.last_line_height; let line_point = self.last_bounds?.localize(&point)?; let lines = self.last_layout.as_ref()?; + for line in lines.iter() { if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { return Some(self.offset_to_utf16(utf8_index)); } } + None } } -impl Focusable for TextInput { +impl Focusable for InputState { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } -impl Render for TextInput { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - const LINE_HEIGHT: Rems = Rems(1.25); - let focused = self.focus_handle.is_focused(window); - - let prefix = self.prefix.as_ref().map(|build| build(window, cx)); - let suffix = self.suffix.as_ref().map(|build| build(window, cx)); - +impl Render for InputState { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() + .id("text-element") .flex_1() - .flex() - .id("input") - .key_context(CONTEXT) - .track_focus(&self.focus_handle) - .when(!self.disabled, |this| { - this.on_action(cx.listener(Self::backspace)) - .on_action(cx.listener(Self::delete)) - .on_action(cx.listener(Self::delete_to_beginning_of_line)) - .on_action(cx.listener(Self::delete_to_end_of_line)) - .on_action(cx.listener(Self::delete_previous_word)) - .on_action(cx.listener(Self::delete_next_word)) - .on_action(cx.listener(Self::enter)) - }) - .on_action(cx.listener(Self::left)) - .on_action(cx.listener(Self::right)) - .on_action(cx.listener(Self::select_left)) - .on_action(cx.listener(Self::select_right)) - .when(self.multi_line, |this| { - this.on_action(cx.listener(Self::up)) - .on_action(cx.listener(Self::down)) - .on_action(cx.listener(Self::select_up)) - .on_action(cx.listener(Self::select_down)) - }) - .on_action(cx.listener(Self::select_all)) - .on_action(cx.listener(Self::select_to_start_of_line)) - .on_action(cx.listener(Self::select_to_end_of_line)) - .on_action(cx.listener(Self::select_to_previous_word)) - .on_action(cx.listener(Self::select_to_next_word)) - .on_action(cx.listener(Self::home)) - .on_action(cx.listener(Self::end)) - .on_action(cx.listener(Self::move_to_start)) - .on_action(cx.listener(Self::move_to_end)) - .on_action(cx.listener(Self::move_to_previous_word)) - .on_action(cx.listener(Self::move_to_next_word)) - .on_action(cx.listener(Self::select_to_start)) - .on_action(cx.listener(Self::select_to_end)) - .on_action(cx.listener(Self::show_character_palette)) - .on_action(cx.listener(Self::copy)) - .on_action(cx.listener(Self::paste)) - .on_action(cx.listener(Self::cut)) - .on_action(cx.listener(Self::undo)) - .on_action(cx.listener(Self::redo)) - .on_action(cx.listener(Self::redo)) - .on_key_down(cx.listener(Self::on_key_down_for_blink_cursor)) - .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) - .on_scroll_wheel(cx.listener(Self::on_scroll_wheel)) - .size_full() - .line_height(LINE_HEIGHT) - .input_py(self.size) - .input_h(self.size) - .input_text_size(self.text_size) - .cursor_text() - .when(self.multi_line, |this| this.h_auto()) - .when(self.appearance, |this| { - this.bg(cx.theme().elevated_surface_background) - .rounded(cx.theme().radius) - .when(focused, |this| this.outline(window, cx)) - .when(prefix.is_none(), |this| this.input_pl(self.size)) - .when(suffix.is_none(), |this| this.input_pr(self.size)) - }) - .children(prefix) - .gap_1() - .items_center() - .child( - div() - .id("TextElement") - .flex_grow() - .overflow_x_hidden() - .child(TextElement::new(cx.entity().clone())), - ) - .when(self.loading, |this| { - this.child(Indicator::new().color(cx.theme().text_muted)) - }) - .children(suffix) - .when(self.is_multi_line(), |this| { - let entity_id = cx.entity().entity_id(); - if self.last_layout.is_some() { - let scroll_size = self.scroll_size; - - this.relative().child( - div() - .absolute() - .top_0() - .left_0() - .right(px(1.)) - .bottom_0() - .child( - Scrollbar::vertical( - entity_id, - self.scrollbar_state.clone(), - self.scroll_handle.clone(), - scroll_size, - ) - .axis(ScrollbarAxis::Vertical), - ), - ) - } else { - this - } - }) + .flex_grow() + .when(self.is_multi_line(), |this| this.h_full()) + .overflow_x_hidden() + .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) } } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs new file mode 100644 index 0000000..f81bb74 --- /dev/null +++ b/crates/ui/src/input/text_input.rs @@ -0,0 +1,302 @@ +use gpui::{ + div, prelude::FluentBuilder as _, px, relative, AnyElement, App, DefiniteLength, Entity, + InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, + Styled, Window, +}; +use theme::ActiveTheme; + +use super::InputState; +use crate::{ + button::{Button, ButtonVariants as _}, + h_flex, + indicator::Indicator, + input::clear_button::clear_button, + scroll::{Scrollbar, ScrollbarAxis}, + IconName, Sizable, Size, StyleSized, +}; + +#[derive(IntoElement)] +pub struct TextInput { + state: Entity, + size: Size, + no_gap: bool, + prefix: Option, + suffix: Option, + height: Option, + appearance: bool, + cleanable: bool, + mask_toggle: bool, + disabled: bool, +} + +impl Sizable for TextInput { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl TextInput { + /// Create a new [`TextInput`] element bind to the [`InputState`]. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + size: Size::default(), + no_gap: false, + prefix: None, + suffix: None, + height: None, + appearance: true, + cleanable: false, + mask_toggle: false, + disabled: false, + } + } + + pub fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + /// Set full height of the input (Multi-line only). + pub fn h_full(mut self) -> Self { + self.height = Some(relative(1.)); + self + } + + /// Set height of the input (Multi-line only). + pub fn h(mut self, height: impl Into) -> Self { + self.height = Some(height.into()); + self + } + + /// Set the appearance of the input field. + pub fn appearance(mut self, appearance: bool) -> Self { + self.appearance = appearance; + 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 to enable toggle button for password mask state. + pub fn mask_toggle(mut self) -> Self { + self.mask_toggle = true; + self + } + + /// Set to disable the input field. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Set true to not use gap between input and prefix, suffix, and clear button. + /// + /// Default: false + #[allow(dead_code)] + pub(super) fn no_gap(mut self) -> Self { + self.no_gap = true; + self + } + + fn render_toggle_mask_button(state: Entity) -> impl IntoElement { + Button::new("toggle-mask") + .icon(IconName::Eye) + .xsmall() + .ghost() + .on_mouse_down(MouseButton::Left, { + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.set_masked(false, window, cx); + }) + } + }) + .on_mouse_up(MouseButton::Left, { + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.set_masked(true, window, cx); + }) + } + }) + } +} + +impl RenderOnce for TextInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + const LINE_HEIGHT: Rems = Rems(1.25); + + self.state.update(cx, |state, _| { + state.height = self.height; + state.disabled = self.disabled; + }); + + let state = self.state.read(cx); + let focused = state.focus_handle.is_focused(window); + + let mut gap_x = match self.size { + Size::Small => px(4.), + Size::Large => px(8.), + _ => px(4.), + }; + + if self.no_gap { + gap_x = px(0.); + } + + let prefix = self.prefix; + let suffix = self.suffix; + + let show_clear_button = + self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line(); + + let bg = if state.disabled { + cx.theme().surface_background + } else { + cx.theme().elevated_surface_background + }; + + div() + .id(("input", self.state.entity_id())) + .flex() + .key_context(crate::input::CONTEXT) + .track_focus(&state.focus_handle) + .when(!state.disabled, |this| { + this.on_action(window.listener_for(&self.state, InputState::backspace)) + .on_action(window.listener_for(&self.state, InputState::delete)) + .on_action( + window.listener_for(&self.state, InputState::delete_to_beginning_of_line), + ) + .on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line)) + .on_action(window.listener_for(&self.state, InputState::delete_previous_word)) + .on_action(window.listener_for(&self.state, InputState::delete_next_word)) + .on_action(window.listener_for(&self.state, InputState::enter)) + .on_action(window.listener_for(&self.state, InputState::escape)) + }) + .on_action(window.listener_for(&self.state, InputState::left)) + .on_action(window.listener_for(&self.state, InputState::right)) + .on_action(window.listener_for(&self.state, InputState::select_left)) + .on_action(window.listener_for(&self.state, InputState::select_right)) + .when(state.multi_line, |this| { + this.on_action(window.listener_for(&self.state, InputState::up)) + .on_action(window.listener_for(&self.state, InputState::down)) + .on_action(window.listener_for(&self.state, InputState::select_up)) + .on_action(window.listener_for(&self.state, InputState::select_down)) + .on_action(window.listener_for(&self.state, InputState::shift_to_new_line)) + }) + .on_action(window.listener_for(&self.state, InputState::select_all)) + .on_action(window.listener_for(&self.state, InputState::select_to_start_of_line)) + .on_action(window.listener_for(&self.state, InputState::select_to_end_of_line)) + .on_action(window.listener_for(&self.state, InputState::select_to_previous_word)) + .on_action(window.listener_for(&self.state, InputState::select_to_next_word)) + .on_action(window.listener_for(&self.state, InputState::home)) + .on_action(window.listener_for(&self.state, InputState::end)) + .on_action(window.listener_for(&self.state, InputState::move_to_start)) + .on_action(window.listener_for(&self.state, InputState::move_to_end)) + .on_action(window.listener_for(&self.state, InputState::move_to_previous_word)) + .on_action(window.listener_for(&self.state, InputState::move_to_next_word)) + .on_action(window.listener_for(&self.state, InputState::select_to_start)) + .on_action(window.listener_for(&self.state, InputState::select_to_end)) + .on_action(window.listener_for(&self.state, InputState::show_character_palette)) + .on_action(window.listener_for(&self.state, InputState::copy)) + .on_action(window.listener_for(&self.state, InputState::paste)) + .on_action(window.listener_for(&self.state, InputState::cut)) + .on_action(window.listener_for(&self.state, InputState::undo)) + .on_action(window.listener_for(&self.state, InputState::redo)) + .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) + .on_mouse_down( + MouseButton::Left, + window.listener_for(&self.state, InputState::on_mouse_down), + ) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&self.state, InputState::on_mouse_up), + ) + .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) + .size_full() + .line_height(LINE_HEIGHT) + .cursor_text() + .input_py(self.size) + .input_h(self.size) + .when(state.multi_line, |this| { + this.h_auto() + .when_some(self.height, |this, height| this.h(height)) + }) + .when(self.appearance, |this| { + this.bg(bg) + .rounded(cx.theme().radius) + .when(focused, |this| this.border_color(cx.theme().ring)) + }) + .when(prefix.is_none(), |this| this.input_pl(self.size)) + .input_pr(self.size) + .items_center() + .gap(gap_x) + .children(prefix) + // TODO: Define height here, and use it in the input element + .child(self.state.clone()) + .child( + h_flex() + .id("suffix") + .absolute() + .gap(gap_x) + .when(self.appearance, |this| this.bg(bg)) + .items_center() + .when(suffix.is_none(), |this| this.pr_1()) + .right_0() + .when(state.loading, |this| { + this.child(Indicator::new().color(cx.theme().text_muted)) + }) + .when(self.mask_toggle, |this| { + this.child(Self::render_toggle_mask_button(self.state.clone())) + }) + .when(show_clear_button, |this| { + this.child(clear_button(cx).on_click({ + let state = self.state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| { + state.clean(window, cx); + }) + } + })) + }) + .children(suffix), + ) + .when(state.is_multi_line(), |this| { + let entity_id = self.state.entity_id(); + + if state.last_layout.is_some() { + let scroll_size = state.scroll_size; + + this.relative().child( + div() + .absolute() + .top_0() + .left_0() + .right(px(1.)) + .bottom_0() + .child( + Scrollbar::vertical( + entity_id, + state.scrollbar_state.clone(), + state.scroll_handle.clone(), + scroll_size, + ) + .axis(ScrollbarAxis::Vertical), + ), + ) + } else { + this + } + }) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 63c0900..34f02ea 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -16,6 +16,7 @@ mod styled; mod title_bar; mod window_border; +pub(crate) mod actions; pub mod animation; pub mod button; pub mod checkbox; diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index e8c4023..11cf9a7 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -1,33 +1,43 @@ use std::{cell::Cell, rc::Rc, time::Duration}; use gpui::{ - actions, div, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context, - Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, - ListSizingBehavior, MouseButton, ParentElement, Render, ScrollStrategy, SharedString, Styled, - Subscription, Task, UniformListScrollHandle, Window, + div, prelude::FluentBuilder, uniform_list, AnyElement, AppContext, Entity, FocusHandle, + Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, + MouseButton, ParentElement, Render, Styled, Task, UniformListScrollHandle, Window, }; +use gpui::{px, App, Context, EventEmitter, MouseDownEvent, ScrollStrategy, Subscription}; use smol::Timer; use theme::ActiveTheme; +use super::loading::Loading; use crate::{ - input::{InputEvent, TextInput}, + actions::{Cancel, Confirm, SelectNext, SelectPrev}, + input::{InputEvent, InputState, TextInput}, scroll::{Scrollbar, ScrollbarState}, - v_flex, Icon, IconName, Size, + v_flex, Icon, IconName, Sizable as _, Size, }; -actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]); - pub fn init(cx: &mut App) { let context: Option<&str> = Some("List"); - cx.bind_keys([ KeyBinding::new("escape", Cancel, context), - KeyBinding::new("enter", Confirm, context), + KeyBinding::new("enter", Confirm { secondary: false }, context), + KeyBinding::new("secondary-enter", Confirm { secondary: true }, context), KeyBinding::new("up", SelectPrev, context), KeyBinding::new("down", SelectNext, context), ]); } +#[derive(Clone)] +pub enum ListEvent { + /// Move to select item. + Select(usize), + /// Click on item or pressed Enter. + Confirm(usize), + /// Pressed ESC to deselect the item. + Cancel, +} + /// A delegate for the List. #[allow(unused)] pub trait ListDelegate: Sized + 'static { @@ -77,9 +87,18 @@ pub trait ListDelegate: Sized + 'static { None } - /// Return the confirmed index of the selected item. - fn confirmed_index(&self, cx: &App) -> Option { - None + /// Returns the loading state to show the loading view. + fn loading(&self, cx: &App) -> bool { + false + } + + /// Returns a Element to show when loading, default is built-in Skeleton loading view. + fn render_loading( + &self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + Loading } /// Set the selected index, just store the ix, don't confirm. @@ -91,29 +110,56 @@ pub trait ListDelegate: Sized + 'static { ); /// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter. - fn confirm(&mut self, ix: Option, window: &mut Window, cx: &mut Context>) {} + /// + /// This will always to `set_selected_index` before confirm. + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {} /// Cancel the selection, e.g.: Pressed ESC. fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} + + /// Return true to enable load more data when scrolling to the bottom. + /// + /// Default: true + fn can_load_more(&self, cx: &App) -> bool { + true + } + + /// Returns a threshold value (n rows), of course, when scrolling to the bottom, + /// the remaining number of rows triggers `load_more`. + /// This should smaller than the total number of first load rows. + /// + /// Default: 20 rows + fn load_more_threshold(&self) -> usize { + 20 + } + + /// Load more data when the table is scrolled to the bottom. + /// + /// This will performed in a background task. + /// + /// This is always called when the table is near the bottom, + /// so you must check if there is more data to load or lock the loading state. + fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} } pub struct List { focus_handle: FocusHandle, delegate: D, max_height: Option, - query_input: Option>, + query_input: Option>, last_query: Option, - loading: bool, - - enable_scrollbar: bool, + selectable: bool, + querying: bool, + scrollbar_visible: bool, vertical_scroll_handle: UniformListScrollHandle, scrollbar_state: Rc>, - pub(crate) size: Size, selected_index: Option, right_clicked_index: Option, + reset_on_cancel: bool, _search_task: Task<()>, - query_input_subscription: Subscription, + _load_more_task: Task<()>, + _query_input_subscription: Subscription, } impl List @@ -121,15 +167,8 @@ where D: ListDelegate, { pub fn new(delegate: D, window: &mut Window, cx: &mut Context) -> Self { - let query_input = cx.new(|cx| { - TextInput::new(window, cx) - .appearance(false) - .prefix(|_window, cx| Icon::new(IconName::Search).text_color(cx.theme().text_muted)) - .placeholder("Search...") - .cleanable() - }); - - let query_input_subscription = + let query_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); + let _query_input_subscription = cx.subscribe_in(&query_input, window, Self::on_query_input_event); Self { @@ -142,21 +181,19 @@ where vertical_scroll_handle: UniformListScrollHandle::new(), scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())), max_height: None, - enable_scrollbar: true, - loading: false, + scrollbar_visible: true, + selectable: true, + querying: false, size: Size::default(), + reset_on_cancel: true, _search_task: Task::ready(()), - query_input_subscription, + _load_more_task: Task::ready(()), + _query_input_subscription, } } /// Set the size - pub fn set_size(&mut self, size: Size, window: &mut Window, cx: &mut Context) { - if let Some(input) = &self.query_input { - input.update(cx, |input, cx| { - input.set_size(size, window, cx); - }) - } + pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context) { self.size = size; } @@ -165,8 +202,9 @@ where self } - pub fn no_scrollbar(mut self) -> Self { - self.enable_scrollbar = false; + /// Set the visibility of the scrollbar, default is true. + pub fn scrollbar_visible(mut self, visible: bool) -> Self { + self.scrollbar_visible = visible; self } @@ -175,17 +213,28 @@ where self } + /// Sets whether the list is selectable, default is true. + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self + } + pub fn set_query_input( &mut self, - query_input: Entity, + query_input: Entity, window: &mut Window, cx: &mut Context, ) { - self.query_input_subscription = + self._query_input_subscription = cx.subscribe_in(&query_input, window, Self::on_query_input_event); self.query_input = Some(query_input); } + /// Get the query input entity. + pub fn query_input(&self) -> Option<&Entity> { + self.query_input.as_ref() + } + pub fn delegate(&self) -> &D { &self.delegate } @@ -198,6 +247,7 @@ where self.focus_handle(cx).focus(window); } + /// Set the selected index of the list, this will also scroll to the selected item. pub fn set_selected_index( &mut self, ix: Option, @@ -206,31 +256,15 @@ where ) { self.selected_index = ix; self.delegate.set_selected_index(ix, window, cx); + self.scroll_to_selected_item(window, cx); } pub fn selected_index(&self) -> Option { self.selected_index } - /// Set the query_input text - pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - if let Some(query_input) = &self.query_input { - let query = query.to_owned(); - query_input.update(cx, |input, cx| input.set_text(query, window, cx)) - } - } - - /// Get the query_input text - pub fn query(&self, _window: &mut Window, cx: &mut Context) -> Option { - self.query_input.as_ref().map(|input| input.read(cx).text()) - } - - fn render_scrollbar( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if !self.enable_scrollbar { + fn render_scrollbar(&self, _: &mut Window, cx: &mut Context) -> Option { + if !self.scrollbar_visible { return None; } @@ -241,6 +275,18 @@ where )) } + /// Scroll to the item at the given index. + pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { + self.vertical_scroll_handle + .scroll_to_item(ix, ScrollStrategy::Top); + cx.notify(); + } + + /// Get scroll handle + pub fn scroll_handle(&self) -> &UniformListScrollHandle { + &self.vertical_scroll_handle + } + fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context) { if let Some(ix) = self.selected_index { self.vertical_scroll_handle @@ -250,7 +296,7 @@ where fn on_query_input_event( &mut self, - _: &Entity, + _: &Entity, event: &InputEvent, window: &mut Window, cx: &mut Context, @@ -262,9 +308,15 @@ where return; } - self.set_loading(true, window, cx); + self.set_querying(true, window, cx); let search = self.delegate.perform_search(&text, window, cx); + if self.delegate.items_count(cx) > 0 { + self.set_selected_index(Some(0), window, cx); + } else { + self.set_selected_index(None, window, cx); + } + self._search_task = cx.spawn_in(window, async move |this, window| { search.await; @@ -277,35 +329,97 @@ where // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; _ = this.update_in(window, |this, window, cx| { - this.set_loading(false, window, cx); + this.set_querying(false, window, cx); }); }); } - InputEvent::PressEnter => self.on_action_confirm(&Confirm, window, cx), + InputEvent::PressEnter { secondary } => self.on_action_confirm( + &Confirm { + secondary: *secondary, + }, + window, + cx, + ), _ => {} } } - fn set_loading(&mut self, loading: bool, _window: &mut Window, cx: &mut Context) { - self.loading = loading; + fn set_querying(&mut self, querying: bool, _: &mut Window, cx: &mut Context) { + self.querying = querying; if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(loading, cx)) + input.update(cx, |input, cx| input.set_loading(querying, cx)) } cx.notify(); } + /// Dispatch delegate's `load_more` method when the visible range is near the end. + fn load_more_if_need( + &mut self, + items_count: usize, + visible_end: usize, + window: &mut Window, + cx: &mut Context, + ) { + let threshold = self.delegate.load_more_threshold(); + // Securely handle subtract logic to prevent attempt to subtract with overflow + if visible_end >= items_count.saturating_sub(threshold) { + if !self.delegate.can_load_more(cx) { + return; + } + + self._load_more_task = cx.spawn_in(window, async move |view, cx| { + _ = view.update_in(cx, |view, window, cx| { + view.delegate.load_more(window, cx); + }); + }); + } + } + + pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self { + self.reset_on_cancel = reset; + self + } + fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.set_selected_index(None, window, cx); + if self.selected_index.is_none() { + cx.propagate(); + } + + if self.reset_on_cancel { + self.set_selected_index(None, window, cx); + } + self.delegate.cancel(window, cx); + cx.emit(ListEvent::Cancel); cx.notify(); } - fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + fn on_action_confirm( + &mut self, + confirm: &Confirm, + window: &mut Window, + cx: &mut Context, + ) { if self.delegate.items_count(cx) == 0 { return; } - self.delegate.confirm(self.selected_index, window, cx); + let Some(ix) = self.selected_index else { + return; + }; + + self.delegate + .set_selected_index(self.selected_index, window, cx); + self.delegate.confirm(confirm.secondary, window, cx); + cx.emit(ListEvent::Confirm(ix)); + cx.notify(); + } + + fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + self.selected_index = Some(ix); + self.delegate.set_selected_index(Some(ix), window, cx); + self.scroll_to_selected_item(window, cx); + cx.emit(ListEvent::Select(ix)); cx.notify(); } @@ -315,21 +429,18 @@ where window: &mut Window, cx: &mut Context, ) { - if self.delegate.items_count(cx) == 0 { + let items_count = self.delegate.items_count(cx); + if items_count == 0 { return; } - let selected_index = self.selected_index.unwrap_or(0); + let mut selected_index = self.selected_index.unwrap_or(0); if selected_index > 0 { - self.selected_index = Some(selected_index - 1); + selected_index -= 1; } else { - self.selected_index = Some(self.delegate.items_count(cx) - 1); + selected_index = items_count - 1; } - - self.delegate - .set_selected_index(self.selected_index, window, cx); - self.scroll_to_selected_item(window, cx); - cx.notify(); + self.select_item(selected_index, window, cx); } fn on_action_select_next( @@ -338,24 +449,25 @@ where window: &mut Window, cx: &mut Context, ) { - if self.delegate.items_count(cx) == 0 { + let items_count = self.delegate.items_count(cx); + if items_count == 0 { return; } - if let Some(selected_index) = self.selected_index { - if selected_index < self.delegate.items_count(cx) - 1 { - self.selected_index = Some(selected_index + 1); + let selected_index; + if let Some(ix) = self.selected_index { + if ix < items_count - 1 { + selected_index = ix + 1; } else { - self.selected_index = Some(0); + // When the last item is selected, select the first item. + selected_index = 0; } } else { - self.selected_index = Some(0); + // When no selected index, select the first item. + selected_index = 0; } - self.delegate - .set_selected_index(self.selected_index, window, cx); - self.scroll_to_selected_item(window, cx); - cx.notify(); + self.select_item(selected_index, window, cx); } fn render_list_item( @@ -364,13 +476,16 @@ where window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let selected = self.selected_index == Some(ix); + let right_clicked = self.right_clicked_index == Some(ix); + div() .id("list-item") .w_full() .relative() .children(self.delegate.render_item(ix, window, cx)) - .when_some(self.selected_index, |this, selected_index| { - this.when(ix == selected_index, |this| { + .when(self.selectable, |this| { + this.when(selected || right_clicked, |this| { this.child( div() .absolute() @@ -378,39 +493,33 @@ where .left(px(0.)) .right(px(0.)) .bottom(px(0.)) - .bg(cx.theme().element_background) + .when(selected, |this| this.bg(cx.theme().element_background)) .border_1() .border_color(cx.theme().border_selected), ) }) - }) - .when(self.right_clicked_index == Some(ix), |this| { - this.child( - div() - .absolute() - .top(px(0.)) - .left(px(0.)) - .right(px(0.)) - .bottom(px(0.)) - .border_1() - .border_color(cx.theme().element_active), + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, ev: &MouseDownEvent, window, cx| { + this.right_clicked_index = None; + this.selected_index = Some(ix); + this.on_action_confirm( + &Confirm { + secondary: ev.modifiers.secondary(), + }, + window, + cx, + ); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, _, _, cx| { + this.right_clicked_index = Some(ix); + cx.notify(); + }), ) }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - this.right_clicked_index = None; - this.selected_index = Some(ix); - this.on_action_confirm(&Confirm, window, cx); - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, _, _window, cx| { - this.right_clicked_index = Some(ix); - cx.notify(); - }), - ) } } @@ -426,7 +535,7 @@ where } } } - +impl EventEmitter for List where D: ListDelegate {} impl Render for List where D: ListDelegate, @@ -435,6 +544,7 @@ where let view = cx.entity().clone(); let vertical_scroll_handle = self.vertical_scroll_handle.clone(); let items_count = self.delegate.items_count(cx); + let loading = self.delegate.loading(cx); let sizing_behavior = if self.max_height.is_some() { ListSizingBehavior::Infer } else { @@ -442,7 +552,7 @@ where }; let initial_view = if let Some(input) = &self.query_input { - if input.read(cx).text().is_empty() { + if input.read(cx).value().is_empty() { self.delegate().render_initial(window, cx) } else { None @@ -458,10 +568,6 @@ where .size_full() .relative() .overflow_hidden() - .on_action(cx.listener(Self::on_action_cancel)) - .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_action_select_next)) - .on_action(cx.listener(Self::on_action_select_prev)) .when_some(self.query_input.clone(), |this, input| { this.child( div() @@ -471,47 +577,73 @@ where }) .border_b_1() .border_color(cx.theme().border) - .child(input), + .child( + TextInput::new(&input) + .with_size(self.size) + .prefix( + Icon::new(IconName::Search).text_color(cx.theme().text_muted), + ) + .cleanable() + .appearance(false), + ), ) }) - .map(|this| { - if let Some(view) = initial_view { - this.child(view) - } else { - this.child( - v_flex() - .flex_grow() - .relative() - .when_some(self.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate().render_empty(window, cx)) - }) - .when(items_count > 0, |this| { - this.child( - uniform_list(view, "uniform-list", items_count, { - move |list, visible_range, window, cx| { - visible_range - .map(|ix| list.render_list_item(ix, window, cx)) - .collect::>() - } - }) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(vertical_scroll_handle) - .into_any_element(), - ) - }) - .children(self.render_scrollbar(window, cx)), - ) - } + .when(loading, |this| { + this.child(self.delegate().render_loading(window, cx)) }) - // Click out to cancel right clicked row - .when(self.right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, _window, cx| { - this.right_clicked_index = None; - cx.notify(); - })) + .when(!loading, |this| { + this.on_action(cx.listener(Self::on_action_cancel)) + .on_action(cx.listener(Self::on_action_confirm)) + .on_action(cx.listener(Self::on_action_select_next)) + .on_action(cx.listener(Self::on_action_select_prev)) + .map(|this| { + if let Some(view) = initial_view { + this.child(view) + } else { + this.child( + v_flex() + .flex_grow() + .relative() + .when_some(self.max_height, |this, h| this.max_h(h)) + .overflow_hidden() + .when(items_count == 0, |this| { + this.child(self.delegate().render_empty(window, cx)) + }) + .when(items_count > 0, |this| { + this.child( + uniform_list(view, "uniform-list", items_count, { + move |list, visible_range, window, cx| { + list.load_more_if_need( + items_count, + visible_range.end, + window, + cx, + ); + + visible_range + .map(|ix| { + list.render_list_item(ix, window, cx) + }) + .collect::>() + } + }) + .flex_grow() + .with_sizing_behavior(sizing_behavior) + .track_scroll(vertical_scroll_handle) + .into_any_element(), + ) + }) + .children(self.render_scrollbar(window, cx)), + ) + } + }) + // Click out to cancel right clicked row + .when(self.right_clicked_index.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.right_clicked_index = None; + cx.notify(); + })) + }) }) } } diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs new file mode 100644 index 0000000..b2e9cb6 --- /dev/null +++ b/crates/ui/src/list/loading.rs @@ -0,0 +1,33 @@ +use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled}; + +use super::ListItem; +use crate::{skeleton::Skeleton, v_flex}; + +#[derive(IntoElement)] +pub struct Loading; + +#[derive(IntoElement)] +struct LoadingItem; + +impl RenderOnce for LoadingItem { + fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement { + ListItem::new("skeleton").disabled(true).child( + v_flex() + .gap_1p5() + .overflow_hidden() + .child(Skeleton::new().h_5().w_48().max_w_full()) + .child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()), + ) + } +} + +impl RenderOnce for Loading { + fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement { + v_flex() + .py_2p5() + .gap_3() + .child(LoadingItem) + .child(LoadingItem) + .child(LoadingItem) + } +} diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index c797298..88baf0f 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,6 +1,7 @@ #[allow(clippy::module_inception)] mod list; mod list_item; +mod loading; pub use list::*; pub use list_item::*; diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 58c694c..7e6c085 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -216,11 +216,9 @@ impl Render for Notification { Some(icon) => icon, None => match self.kind { NotificationType::Info => Icon::new(IconName::Info).text_color(blue()), + NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()), NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()), NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()), - NotificationType::Warning => { - Icon::new(IconName::TriangleAlert).text_color(yellow()) - } }, }; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 4f97974..6250db2 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -7,6 +7,7 @@ use gpui::{ use theme::ActiveTheme; use crate::{ + input::InputState, modal::Modal, notification::{Notification, NotificationList}, window_border, @@ -36,6 +37,12 @@ pub trait ContextModal: Sized { /// Clear all notifications fn clear_notifications(&mut self, cx: &mut App); + + /// Return current focused Input entity. + fn focused_input(&mut self, cx: &mut App) -> Option>; + + /// Returns true if there is a focused Input entity. + fn has_focused_input(&mut self, cx: &mut App) -> bool; } impl ContextModal for Window { @@ -110,12 +117,20 @@ impl ContextModal for Window { let entity = Root::read(self, cx).notification.clone(); Rc::new(entity.read(cx).notifications()) } + + fn has_focused_input(&mut self, cx: &mut App) -> bool { + Root::read(self, cx).focused_input.is_some() + } + + fn focused_input(&mut self, cx: &mut App) -> Option> { + Root::read(self, cx).focused_input.clone() + } } type Builder = Rc Modal + 'static>; #[derive(Clone)] -struct ActiveModal { +pub struct ActiveModal { focus_handle: FocusHandle, builder: Builder, } @@ -124,11 +139,13 @@ struct ActiveModal { /// /// It is used to manage the Modal, and Notification. pub struct Root { + pub active_modals: Vec, + pub notification: Entity, + pub focused_input: Option>, /// Used to store the focus handle of the previous view. + /// /// When the Modal closes, we will focus back to the previous view. previous_focus_handle: Option, - active_modals: Vec, - pub notification: Entity, view: AnyView, } @@ -136,6 +153,7 @@ impl Root { pub fn new(view: AnyView, window: &mut Window, cx: &mut Context) -> Self { Self { previous_focus_handle: None, + focused_input: None, active_modals: Vec::new(), notification: cx.new(|cx| NotificationList::new(window, cx)), view, diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index 327a4e1..a944e88 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -9,14 +9,21 @@ use theme::ActiveTheme; #[derive(IntoElement)] pub struct Skeleton { base: Div, + secondary: bool, } impl Skeleton { pub fn new() -> Self { Self { base: div().w_full().h_4().rounded_md(), + secondary: false, } } + + pub fn secondary(mut self, secondary: bool) -> Self { + self.secondary = secondary; + self + } } impl Default for Skeleton { @@ -33,19 +40,23 @@ impl Styled for Skeleton { impl RenderOnce for Skeleton { fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let color = if self.secondary { + cx.theme().ghost_element_active.opacity(0.5) + } else { + cx.theme().ghost_element_active + }; + div().child( - self.base - .bg(cx.theme().ghost_element_active) - .with_animation( - "skeleton", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(bounce(ease_in_out)), - move |this, delta| { - let v = 1.0 - delta * 0.5; - this.opacity(v) - }, - ), + self.base.bg(color).with_animation( + "skeleton", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(bounce(ease_in_out)), + move |this, delta| { + let v = 1.0 - delta * 0.5; + this.opacity(v) + }, + ), ) } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index e46c0e9..f33fac8 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -135,7 +135,7 @@ pub trait Sizable: Sized { #[allow(unused)] pub trait StyleSized { - fn input_text_size(self, size: Size) -> Self; + fn input_font_size(self, size: Size) -> Self; fn input_size(self, size: Size) -> Self; fn input_pl(self, size: Size) -> Self; fn input_pr(self, size: Size) -> Self; @@ -150,7 +150,7 @@ pub trait StyleSized { } impl StyleSized for T { - fn input_text_size(self, size: Size) -> Self { + fn input_font_size(self, size: Size) -> Self { match size { Size::XSmall => self.text_xs(), Size::Small => self.text_sm(), @@ -203,11 +203,11 @@ impl StyleSized for T { Size::Large => self.h_12(), _ => self.h(px(24.)), } - .input_text_size(size) + .input_font_size(size) } fn list_size(self, size: Size) -> Self { - self.list_px(size).list_py(size).input_text_size(size) + self.list_px(size).list_py(size).input_font_size(size) } fn list_px(self, size: Size) -> Self {