migrate to gpui-component

This commit is contained in:
2026-06-02 18:15:54 +07:00
parent 5d4c8634ef
commit bac04ab4da
116 changed files with 1165 additions and 24445 deletions

532
Cargo.lock generated
View File

@@ -260,6 +260,15 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -763,6 +772,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "base62"
version = "2.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b"
[[package]]
name = "base64"
version = "0.22.1"
@@ -1170,6 +1185,7 @@ dependencies = [
"common",
"flume 0.11.1",
"gpui",
"gpui-component",
"gpui_tokio",
"itertools 0.13.0",
"linkify",
@@ -1185,8 +1201,6 @@ dependencies = [
"smallvec",
"smol",
"state",
"theme",
"ui",
]
[[package]]
@@ -1396,6 +1410,7 @@ dependencies = [
"dirs 5.0.1",
"futures",
"gpui",
"gpui-component",
"itertools 0.13.0",
"log",
"nostr",
@@ -1497,6 +1512,7 @@ dependencies = [
"device",
"futures",
"gpui",
"gpui-component",
"gpui_linux",
"gpui_macos",
"gpui_platform",
@@ -1517,10 +1533,8 @@ dependencies = [
"smallvec",
"smol",
"state",
"theme",
"title_bar",
"tracing-subscriber",
"ui",
"webbrowser",
]
@@ -1553,10 +1567,8 @@ dependencies = [
"smallvec",
"smol",
"state",
"theme",
"tracing-subscriber",
"tracing-wasm",
"ui",
"wasm-bindgen",
"webbrowser",
]
@@ -1857,6 +1869,7 @@ dependencies = [
"common",
"flume 0.11.1",
"gpui",
"gpui-component",
"itertools 0.13.0",
"log",
"nostr-sdk",
@@ -1866,8 +1879,6 @@ dependencies = [
"smallvec",
"smol",
"state",
"theme",
"ui",
]
[[package]]
@@ -2053,6 +2064,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-iterator"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016"
dependencies = [
"enum-iterator-derive",
]
[[package]]
name = "enum-iterator-derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -2451,6 +2482,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "ftree"
version = "1.3.0"
@@ -2733,6 +2773,17 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc"
dependencies = [
"bitflags 1.3.2",
"ignore",
"walkdir",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -2881,6 +2932,59 @@ dependencies = [
"zed-scap",
]
[[package]]
name = "gpui-component"
version = "0.5.2"
source = "git+https://github.com/reyakov/gpui-component#af5eb24c00da145d5145cbad87f14e0f9234b094"
dependencies = [
"aho-corasick",
"anyhow",
"async-channel 2.5.0",
"chrono",
"core-text",
"enum-iterator",
"futures",
"gpui",
"gpui-component-macros",
"gpui_macros",
"html5ever",
"instant",
"itertools 0.13.0",
"log",
"lsp-types",
"markdown",
"markup5ever_rcdom",
"notify",
"num-traits",
"once_cell",
"paste",
"regex",
"ropey",
"rust-i18n",
"schemars",
"serde",
"serde_json",
"serde_repr",
"smallvec",
"smol",
"tracing",
"tree-sitter",
"tree-sitter-json",
"unicode-segmentation",
"uuid",
"zed-sum-tree",
]
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/reyakov/gpui-component#af5eb24c00da145d5145cbad87f14e0f9234b094"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gpui_linux"
version = "0.1.0"
@@ -3331,6 +3435,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "html5ever"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
dependencies = [
"log",
"mac",
"markup5ever",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "http"
version = "1.4.1"
@@ -3598,6 +3716,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -3665,6 +3799,26 @@ dependencies = [
"ftree",
]
[[package]]
name = "inotify"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -3682,6 +3836,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
@@ -3747,6 +3904,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -3884,6 +4050,26 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.11.1",
"libc",
]
[[package]]
name = "kurbo"
version = "0.11.3"
@@ -4201,6 +4387,42 @@ dependencies = [
"libc",
]
[[package]]
name = "markdown"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
dependencies = [
"serde",
"unicode-id",
]
[[package]]
name = "markup5ever"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
dependencies = [
"log",
"phf 0.11.3",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "markup5ever_rcdom"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
dependencies = [
"html5ever",
"markup5ever",
"tendril",
"xml5ever",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4332,6 +4554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -4465,6 +4688,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "normpath"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nostr"
version = "0.44.1"
@@ -4577,6 +4809,34 @@ dependencies = [
"universal-time",
]
[[package]]
name = "notify"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.1",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.52.0",
]
[[package]]
name = "notify-types"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
dependencies = [
"instant",
]
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -5195,6 +5455,16 @@ dependencies = [
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -5433,6 +5703,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "presser"
version = "0.3.1"
@@ -5946,14 +6222,13 @@ dependencies = [
"common",
"flume 0.11.1",
"gpui",
"gpui-component",
"log",
"nostr-sdk",
"settings",
"smallvec",
"smol",
"state",
"theme",
"ui",
]
[[package]]
@@ -6059,18 +6334,12 @@ dependencies = [
]
[[package]]
name = "rope"
version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#46ac5758a5562f05b786b88cf3600b9334abeb7d"
name = "ropey"
version = "2.0.0-beta.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74"
dependencies = [
"heapless 0.9.3",
"log",
"rayon",
"sum_tree",
"tracing",
"unicode-segmentation",
"util",
"ztracing",
"str_indices",
]
[[package]]
@@ -6114,6 +6383,54 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust-i18n"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40"
dependencies = [
"globwalk",
"regex",
"rust-i18n-macro",
"rust-i18n-support",
"smallvec",
]
[[package]]
name = "rust-i18n-macro"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30de488acadcf767d97cd48518a8da8ea9777b1c9a5beca4eab78bbf77d07309"
dependencies = [
"glob",
"proc-macro2",
"quote",
"rust-i18n-support",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]]
name = "rust-i18n-support"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aea0fef8a93c06326b66392c95a115120e609674cb2132d37d276a6b05b545b4"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"normpath",
"serde",
"serde_json",
"serde_yaml",
"siphasher",
"toml 0.8.23",
"triomphe",
]
[[package]]
name = "rust-ini"
version = "0.17.0"
@@ -6600,6 +6917,19 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "settings"
version = "1.0.0-beta4"
@@ -6614,7 +6944,6 @@ dependencies = [
"serde_json",
"smallvec",
"smol",
"theme",
]
[[package]]
@@ -6889,6 +7218,18 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "str_indices"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -6898,6 +7239,31 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -7195,20 +7561,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "theme"
version = "1.0.0-beta4"
dependencies = [
"anyhow",
"gpui",
"log",
"schemars",
"serde",
"serde_json",
"smallvec",
"tempfile",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -7339,13 +7691,12 @@ dependencies = [
"anyhow",
"common",
"gpui",
"gpui-component",
"linicon",
"log",
"nostr-sdk",
"smallvec",
"smol",
"theme",
"ui",
"windows 0.61.3",
]
@@ -7648,6 +7999,47 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "tree-sitter"
version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3"
dependencies = [
"cc",
"regex",
"regex-syntax",
"serde_json",
"streaming-iterator",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-language"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "triomphe"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
dependencies = [
"arc-swap",
"serde",
"stable_deref_trait",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7705,29 +8097,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ui"
version = "1.0.0-beta4"
dependencies = [
"anyhow",
"common",
"gpui",
"image",
"itertools 0.13.0",
"log",
"lsp-types",
"regex",
"rope",
"serde",
"serde_json",
"smallvec",
"smol",
"sum_tree",
"theme",
"unicode-segmentation",
"uuid",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -7752,6 +8121,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-id"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -7825,6 +8200,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f942cacf37a673350e8f252dff4691fb8e525a5fff6e1f2c4b6c2f3b8349248d"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -9325,6 +9706,12 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "workspace-hack"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01"
[[package]]
name = "writeable"
version = "0.6.3"
@@ -9438,6 +9825,17 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "xml5ever"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
dependencies = [
"log",
"mac",
"markup5ever",
]
[[package]]
name = "xmlwriter"
version = "0.1.0"
@@ -9681,6 +10079,18 @@ dependencies = [
"xcb",
]
[[package]]
name = "zed-sum-tree"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35"
dependencies = [
"arrayvec",
"log",
"rayon",
"workspace-hack",
]
[[package]]
name = "zed-xim"
version = "0.4.0-zed"

View File

@@ -16,6 +16,7 @@ gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
gpui-component = { git = "https://github.com/reyakov/gpui-component" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr

View File

@@ -6,8 +6,6 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
@@ -15,6 +13,7 @@ settings = { path = "../settings" }
gpui.workspace = true
gpui_tokio.workspace = true
gpui-component.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true

View File

@@ -4,15 +4,26 @@ use std::sync::Arc;
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus};
use common::{TimestampExt, coop_cache};
use common::{CoopIcon, TimestampExt, coop_cache};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
AnyElement, App, AppContext, ClipboardItem, Context, Element, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState,
MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window,
deferred, div, img, list, px, red, relative, svg, white,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::menu::DropdownMenu;
use gpui_component::notification::Notification;
use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Disableable, Icon, InteractiveElementExt, Sizable, StyledExt, WindowExt, h_flex,
v_flex,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
@@ -20,18 +31,6 @@ use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use smol::lock::RwLock;
use state::{NostrRegistry, upload};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::DropdownMenu;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
h_flex, v_flex,
};
use crate::text::RenderedText;
@@ -119,7 +118,7 @@ impl ChatPanel {
InputState::new(window, cx)
.placeholder(format!("Message {}", name))
.auto_grow(1, 20)
.prevent_new_line_on_enter()
.submit_on_enter(true)
.clean_on_escape()
});
@@ -674,8 +673,8 @@ impl ChatPanel {
let chat = ChatRegistry::global(cx);
let seen_on = chat.read(cx).rumor_seen_on(id);
window.open_modal(cx, move |this, _window, cx| {
this.title("Seen on").show_close(true).child(
window.open_dialog(cx, move |this, _window, cx| {
this.title("Seen on").close_button(true).child(
v_flex()
.gap_1()
.when_none(&seen_on, |this| {
@@ -684,7 +683,7 @@ impl ChatPanel {
.h_10()
.justify_center()
.text_sm()
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child("Message isn't traced yet"),
)
@@ -699,7 +698,7 @@ impl ChatPanel {
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.text_sm()
.child(div().size_1p5().rounded_full().bg(gpui::green()))
@@ -717,11 +716,11 @@ impl ChatPanel {
fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let profile = self.profile(public_key, cx);
window.open_modal(cx, move |this, _window, cx| {
window.open_dialog(cx, move |this, _window, cx| {
let relays = profile.messaging_relays();
this.title("Messaging Relays")
.show_close(true)
.close_button(true)
.child(v_flex().gap_1().children({
let mut items = vec![];
@@ -731,7 +730,7 @@ impl ChatPanel {
.h_7()
.px_2()
.gap_2()
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.text_sm()
.child(div().size_1p5().rounded_full().bg(gpui::green()))
@@ -760,13 +759,13 @@ impl ChatPanel {
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().muted_foreground)
.line_height(relative(1.3))
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().ghost_element_active),
.text_color(cx.theme().muted_foreground),
)
.child(SharedString::from(ANNOUNCEMENT))
.into_any_element()
@@ -789,9 +788,9 @@ impl ChatPanel {
.size_8()
.justify_center()
.rounded_full()
.bg(cx.theme().warning_background)
.bg(cx.theme().warning)
.text_color(cx.theme().warning_foreground)
.child(Icon::new(IconName::Warning).small()),
.child(Icon::new(CoopIcon::Warning).small()),
)
.child(
div()
@@ -867,15 +866,11 @@ impl ChatPanel {
.gap_3()
.when(!hide_avatar, |this| {
this.child(
Avatar::new(author.avatar())
Avatar::new()
.src(author.avatar())
.name(author.name())
.flex_shrink_0()
.relative()
.dropdown_menu(move |this, _window, _cx| {
this.menu("Public Key", Box::new(Command::Copy(pk)))
.menu("View Relays", Box::new(Command::Relays(pk)))
.separator()
.menu("View on njump.me", Box::new(Command::Njump(pk)))
}),
.relative(),
)
})
.child(
@@ -888,17 +883,17 @@ impl ChatPanel {
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.text_color(cx.theme().muted_foreground)
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.text_color(cx.theme().foreground)
.child(author.name()),
)
.when(encrypted_by_dekey, |this| {
this.child(
Button::new(format!("dekey-{ix}"))
.icon(IconName::Shield)
.icon(CoopIcon::Shield)
.ghost()
.xsmall()
.px_4()
@@ -920,13 +915,13 @@ impl ChatPanel {
)
.child(
div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.group_hover("", |this| this.bg(cx.theme().primary_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent),
.bg(cx.theme().transparent),
)
.child(self.render_actions(&id, &pk, cx))
.on_mouse_down(
@@ -938,7 +933,7 @@ impl ChatPanel {
.on_double_click(cx.listener(move |this, _, _window, cx| {
this.reply_to(&id, cx);
}))
.hover(|this| this.bg(cx.theme().surface_background))
.hover(|this| this.bg(cx.theme().muted))
.into_any_element()
}
@@ -953,7 +948,7 @@ impl ChatPanel {
return div().child(
img(media[0].clone())
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border)
.h(px(250.))
.object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius),
@@ -981,7 +976,7 @@ impl ChatPanel {
img(item.clone())
.h_32()
.border_1()
.border_color(cx.theme().border_variant)
.border_color(cx.theme().border)
.rounded(cx.theme().radius),
),
);
@@ -1010,11 +1005,11 @@ impl ChatPanel {
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.border_color(cx.theme().primary_active)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.text_color(cx.theme().accent_foreground)
.child(author.name()),
)
.child(
@@ -1024,7 +1019,7 @@ impl ChatPanel {
.line_clamp(1)
.child(SharedString::from(&message.content)),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.hover(|this| this.bg(cx.theme().muted))
.on_click({
let id = *id;
cx.listener(move |this, _event, _window, _cx| {
@@ -1065,15 +1060,15 @@ impl ChatPanel {
div()
.id(SharedString::from(id.to_hex()))
.child(label)
.when(failed, |this| this.text_color(cx.theme().text_danger))
.when(failed, |this| this.text_color(cx.theme().danger_foreground))
.when_some(reports, |this, reports| {
this.when(!pending, |this| {
this.on_click(move |_e, window, cx| {
let reports = reports.clone();
window.open_modal(cx, move |this, _window, cx| {
window.open_dialog(cx, move |this, _window, cx| {
this.title(SharedString::from("Sent Reports"))
.show_close(true)
.close_button(true)
.child(v_flex().gap_4().children({
let mut items = Vec::with_capacity(reports.len());
@@ -1107,7 +1102,7 @@ impl ChatPanel {
h_flex()
.gap_1()
.font_semibold()
.child(Avatar::new(avatar).small())
.child(Avatar::new().src(avatar).name(name.clone()).small())
.child(name.clone()),
),
)
@@ -1121,7 +1116,7 @@ impl ChatPanel {
.w_full()
.text_sm()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.bg(cx.theme().warning)
.text_color(cx.theme().warning_foreground)
.child(div().flex_1().w_full().text_center().child(error)),
)
@@ -1141,7 +1136,7 @@ impl ChatPanel {
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().danger_background)
.bg(cx.theme().danger)
.child(
div()
.text_xs()
@@ -1171,7 +1166,7 @@ impl ChatPanel {
.p_1()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.child(
div()
.text_xs()
@@ -1214,7 +1209,7 @@ impl ChatPanel {
.bg(cx.theme().background)
.child(
Button::new("reply")
.icon(IconName::Reply)
.icon(CoopIcon::Reply)
.tooltip("Reply")
.small()
.ghost()
@@ -1227,7 +1222,7 @@ impl ChatPanel {
)
.child(
Button::new("copy")
.icon(IconName::Copy)
.icon(CoopIcon::Copy)
.tooltip("Copy")
.small()
.ghost()
@@ -1241,7 +1236,7 @@ impl ChatPanel {
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("advance")
.icon(IconName::Ellipsis)
.icon(CoopIcon::Ellipsis)
.small()
.ghost()
.dropdown_menu({
@@ -1279,7 +1274,7 @@ impl ChatPanel {
.justify_center()
.rounded_full()
.bg(red())
.child(Icon::new(IconName::Close).size_2().text_color(white())),
.child(Icon::new(CoopIcon::Close).size_2().text_color(white())),
)
.on_click({
let url = url.clone();
@@ -1312,7 +1307,7 @@ impl ChatPanel {
.w_full()
.pl_2()
.border_l_2()
.border_color(cx.theme().element_active)
.border_color(cx.theme().secondary_active)
.child(
div()
.flex()
@@ -1324,17 +1319,17 @@ impl ChatPanel {
.items_baseline()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Replying to:"))
.child(
div()
.text_color(cx.theme().text_accent)
.text_color(cx.theme().secondary_foreground)
.child(profile.name()),
),
)
.child(
Button::new("remove-reply")
.icon(IconName::Close)
.icon(CoopIcon::Close)
.xsmall()
.ghost()
.on_click({
@@ -1382,7 +1377,7 @@ impl ChatPanel {
.unwrap_or((true, SignerKind::default()));
Button::new("encryption")
.icon(IconName::Settings2)
.icon(CoopIcon::Settings2)
.tooltip("Configuration")
.ghost()
.large()
@@ -1418,11 +1413,11 @@ impl ChatPanel {
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
Button::new("emoji")
.icon(IconName::Emoji)
.icon(CoopIcon::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
this.horizontal()
this.separator()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
@@ -1436,11 +1431,11 @@ impl ChatPanel {
}
impl Panel for ChatPanel {
fn panel_id(&self) -> SharedString {
self.id.clone()
fn panel_name(&self) -> &'static str {
"Chat"
}
fn title(&self, cx: &App) -> AnyElement {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.room
.read_with(cx, |this, cx| {
let label = this.display_name(cx);
@@ -1448,19 +1443,24 @@ impl Panel for ChatPanel {
h_flex()
.gap_1p5()
.child(Avatar::new(url).xsmall())
.child(Avatar::new().src(url).xsmall())
.child(label)
.into_any_element()
})
.unwrap_or(div().child("Unknown").into_any_element())
.unwrap_or(div().child("Unknown").into_any())
.into_any()
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
fn toolbar_buttons(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Vec<Button>> {
let subject_bar = self.subject_bar.clone();
vec![
Some(vec![
Button::new("subject")
.icon(IconName::Input)
.icon(CoopIcon::Input)
.tooltip("Change subject")
.small()
.ghost()
@@ -1470,7 +1470,7 @@ impl Panel for ChatPanel {
cx.notify();
});
}),
]
])
}
}
@@ -1497,15 +1497,10 @@ impl Render for ChatPanel {
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child(
TextInput::new(&self.subject_input)
.text_sm()
.small()
.bordered(false),
)
.child(Input::new(&self.subject_input).text_sm().bordered(false))
.child(
Button::new("change")
.icon(IconName::CheckCircle)
.icon(CoopIcon::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
@@ -1543,7 +1538,7 @@ impl Render for ChatPanel {
.items_end()
.child(
Button::new("upload")
.icon(IconName::Plus)
.icon(CoopIcon::Plus)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
@@ -1553,12 +1548,7 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
.child(
TextInput::new(&self.input)
.appearance(false)
.text_sm()
.flex_1(),
)
.child(Input::new(&self.input).appearance(false).text_sm().flex_1())
.child(
h_flex()
.pl_1()
@@ -1567,7 +1557,7 @@ impl Render for ChatPanel {
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::PaperPlaneFill)
.icon(CoopIcon::PaperPlaneFill)
.disabled(self.uploading)
.ghost()
.large()

View File

@@ -7,8 +7,8 @@ use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use gpui_component::ActiveTheme;
use person::PersonRegistry;
use theme::ActiveTheme;
#[allow(clippy::enum_variant_names)]
#[allow(dead_code)]
@@ -68,8 +68,8 @@ impl RenderedText {
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
let code_background = cx.theme().secondary;
let color = cx.theme().primary_active;
InteractiveText::new(
id,

View File

@@ -6,6 +6,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-component.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true

132
crates/common/src/icons.rs Normal file
View File

@@ -0,0 +1,132 @@
use gpui_component::IconNamed;
pub enum CoopIcon {
ArrowLeft,
ArrowRight,
Boom,
Book,
ChevronDown,
CaretDown,
CaretRight,
CaretUp,
Check,
CheckCircle,
Close,
CloseCircle,
CloseCircleFill,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Input,
Info,
Invite,
Inbox,
InboxFill,
Link,
Loader,
Moon,
Plus,
PlusCircle,
Profile,
Reset,
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
UserKey,
Upload,
Usb,
PanelLeft,
PanelLeftOpen,
PanelRight,
PanelRightOpen,
PanelBottom,
PanelBottomOpen,
PaperPlaneFill,
Warning,
WindowClose,
WindowMaximize,
WindowMinimize,
WindowRestore,
Fistbump,
FistbumpFill,
Zoom,
}
impl IconNamed for CoopIcon {
fn path(self) -> gpui::SharedString {
match self {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
Self::Check => "icons/check.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::PanelBottom => "icons/panel-bottom.svg",
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Warning => "icons/warning.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
Self::Fistbump => "icons/fistbump.svg",
Self::FistbumpFill => "icons/fistbump-fill.svg",
Self::Zoom => "icons/zoom.svg",
}
.into()
}
}

View File

@@ -2,6 +2,7 @@ pub use caching::*;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use icons::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
@@ -11,6 +12,7 @@ mod caching;
mod debounced_delay;
mod display;
mod event;
mod icons;
mod media_extractor;
mod parser;
mod paths;

View File

@@ -8,10 +8,10 @@ publish.workspace = true
common = { path = "../common" }
state = { path = "../state" }
person = { path = "../person" }
ui = { path = "../ui" }
theme = { path = "../theme" }
gpui.workspace = true
gpui-component.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true

View File

@@ -9,14 +9,13 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::Button;
use gpui_component::notification::{Notification, NotificationType};
use gpui_component::{ActiveTheme, Disableable, Sizable, StyledExt, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{Announcement, CLIENT_NAME, NostrRegistry, StateEvent, TIMEOUT};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device";
const MSG: &str = "You've requested an encryption key from another device. \
@@ -553,7 +552,7 @@ impl DeviceRegistry {
match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.clear_notification_by_id::<DeviceNotification>(id, cx);
window.remove_notification1::<DeviceNotification>(id, cx);
})
.ok();
}
@@ -595,9 +594,9 @@ impl DeviceRegistry {
let key = SharedString::from(event.id.to_hex());
Notification::new()
.type_id::<DeviceNotification>(key)
.id1::<DeviceNotification>(key)
.autohide(false)
.with_kind(NotificationKind::Info)
.with_type(NotificationType::Info)
.title("Encryption Key Request")
.content(move |_this, _window, cx| {
v_flex()
@@ -620,7 +619,7 @@ impl DeviceRegistry {
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("From:")),
)
.child(
@@ -629,11 +628,16 @@ impl DeviceRegistry {
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).xsmall())
.child(
Avatar::new()
.src(profile.avatar())
.name(profile.name())
.xsmall(),
)
.child(profile.name()),
),
),
@@ -646,7 +650,7 @@ impl DeviceRegistry {
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Client:")),
)
.child(
@@ -655,7 +659,7 @@ impl DeviceRegistry {
.w_full()
.px_2()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.bg(cx.theme().muted)
.child(request.client_name()),
),
),

View File

@@ -8,10 +8,10 @@ publish.workspace = true
state = { path = "../state" }
settings = { path = "../settings" }
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
gpui.workspace = true
gpui-component.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true

View File

@@ -10,14 +10,13 @@ use gpui::{
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
Task, Window, div, relative,
};
use gpui_component::button::Button;
use gpui_component::notification::{Notification, NotificationType};
use gpui_component::{ActiveTheme, Disableable, WindowExt, v_flex};
use nostr_sdk::prelude::*;
use settings::{AppSettings, AuthMode};
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::Button;
use ui::notification::{Notification, NotificationKind};
use ui::{Disableable, WindowExtension, v_flex};
const AUTH_MESSAGE: &str =
"Approve the authentication request to allow Coop to continue sending or receiving events.";
@@ -270,7 +269,7 @@ impl RelayAuth {
let url = req.url();
this.update_in(cx, |this, window, cx| {
window.clear_notification_by_id::<AuthNotification>(challenge, cx);
window.remove_notification1::<AuthNotification>(challenge, cx);
match result {
Ok(_) => {
@@ -325,9 +324,9 @@ impl RelayAuth {
let loading = Rc::new(Cell::new(false));
Notification::new()
.type_id::<AuthNotification>(challenge)
.id1::<AuthNotification>(challenge)
.autohide(false)
.with_kind(NotificationKind::Info)
.with_type(NotificationType::Info)
.title("Authentication Required")
.content(move |_this, _window, cx| {
v_flex()
@@ -344,8 +343,8 @@ impl RelayAuth {
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().elevated_surface_background)
.text_color(cx.theme().text)
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.child(url.clone()),
)
.into_any_element()

View File

@@ -5,7 +5,6 @@ edition.workspace = true
publish.workspace = true
[dependencies]
theme = { path = "../theme" }
common = { path = "../common" }
nostr-sdk.workspace = true

View File

@@ -1,6 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::rc::Rc;
use anyhow::{Error, anyhow};
use common::config_dir;
@@ -8,7 +7,6 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window}
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
@@ -36,8 +34,6 @@ macro_rules! setting_accessors {
}
setting_accessors! {
pub theme: Option<String>,
pub theme_mode: ThemeMode,
pub hide_avatar: bool,
pub screening: bool,
pub auth_mode: AuthMode,
@@ -125,12 +121,6 @@ impl RoomConfig {
/// Settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// Theme
pub theme: Option<String>,
/// Theme mode
pub theme_mode: ThemeMode,
/// Hide user avatars
pub hide_avatar: bool,
@@ -153,8 +143,6 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
theme: None,
theme_mode: ThemeMode::default(),
hide_avatar: false,
screening: true,
auth_mode: AuthMode::default(),
@@ -238,9 +226,8 @@ impl AppSettings {
let settings = task.await.unwrap_or(Settings::default());
// Update settings
this.update_in(cx, |this, window, cx| {
this.update_in(cx, |this, _window, cx| {
this.set_settings(settings, cx);
this.apply_theme(window, cx);
})
.ok();
})
@@ -264,43 +251,6 @@ impl AppSettings {
task.detach();
}
/// Set theme
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
where
T: Into<String>,
{
// Update settings
self.values.theme = Some(theme.into());
cx.notify();
// Apply the new theme
self.apply_theme(window, cx);
}
/// Reset theme
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.values.theme = None;
cx.notify();
self.apply_theme(window, cx);
}
/// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(name) = self.values.theme.as_ref() {
let mode = self.values.theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
Theme::change(mode, Some(window), cx);
} else {
log::info!("Failed to load theme: {name}");
}
} else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
}
}
/// Check if the given relay is already authenticated
pub fn trusted_relay(&self, url: &RelayUrl, _cx: &App) -> bool {
self.values.trusted_relays.iter().any(|relay| {

View File

@@ -1,17 +0,0 @@
[package]
name = "theme"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
schemars.workspace = true
smallvec.workspace = true
[dev-dependencies]
tempfile = "3.10"

View File

@@ -1 +0,0 @@
../../LICENSE

View File

@@ -1 +0,0 @@
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)

File diff suppressed because it is too large Load Diff

View File

@@ -1,159 +0,0 @@
use std::fmt::{self, Debug, Display, Formatter};
use gpui::{AbsoluteLength, Axis, Length, Pixels};
use serde::{Deserialize, Serialize};
/// A enum for defining the placement of the element.
///
/// See also: [`Side`] if you need to define the left, right side.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Placement {
#[serde(rename = "top")]
Top,
#[serde(rename = "bottom")]
Bottom,
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Display for Placement {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Placement::Top => write!(f, "Top"),
Placement::Bottom => write!(f, "Bottom"),
Placement::Left => write!(f, "Left"),
Placement::Right => write!(f, "Right"),
}
}
}
impl Placement {
#[inline]
pub fn is_horizontal(&self) -> bool {
matches!(self, Placement::Left | Placement::Right)
}
#[inline]
pub fn is_vertical(&self) -> bool {
matches!(self, Placement::Top | Placement::Bottom)
}
#[inline]
pub fn axis(&self) -> Axis {
match self {
Placement::Top | Placement::Bottom => Axis::Vertical,
Placement::Left | Placement::Right => Axis::Horizontal,
}
}
}
/// A enum for defining the side of the element.
///
/// See also: [`Placement`] if you need to define the 4 edges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Side {
#[serde(rename = "left")]
Left,
#[serde(rename = "right")]
Right,
}
impl Side {
/// Returns true if the side is left.
#[inline]
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
/// Returns true if the side is right.
#[inline]
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// A trait to extend the [`Axis`] enum with utility methods.
pub trait AxisExt {
#[allow(clippy::wrong_self_convention)]
fn is_horizontal(self) -> bool;
#[allow(clippy::wrong_self_convention)]
fn is_vertical(self) -> bool;
}
impl AxisExt for Axis {
#[inline]
fn is_horizontal(self) -> bool {
self == Axis::Horizontal
}
#[inline]
fn is_vertical(self) -> bool {
self == Axis::Vertical
}
}
/// A trait for converting [`Pixels`] to `f32` and `f64`.
pub trait PixelsExt {
fn as_f32(&self) -> f32;
#[allow(clippy::wrong_self_convention)]
fn as_f64(self) -> f64;
}
impl PixelsExt for Pixels {
fn as_f32(&self) -> f32 {
f32::from(self)
}
fn as_f64(self) -> f64 {
f64::from(self)
}
}
/// A trait to extend the [`Length`] enum with utility methods.
pub trait LengthExt {
/// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`.
///
/// If the [`Length`] is [`Length::Auto`], it returns `None`.
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels>;
}
impl LengthExt for Length {
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
match self {
Length::Auto => None,
Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)),
}
}
}
/// A struct for defining the edges of an element.
///
/// A extend version of [`gpui::Edges`] to serialize/deserialize.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)]
#[repr(C)]
pub struct Edges<T: Clone + Debug + Default + PartialEq> {
/// The size of the top edge.
pub top: T,
/// The size of the right edge.
pub right: T,
/// The size of the bottom edge.
pub bottom: T,
/// The size of the left edge.
pub left: T,
}
impl<T> Edges<T>
where
T: Clone + Debug + Default + PartialEq,
{
/// Creates a new `Edges` instance with all edges set to the same value.
pub fn all(value: T) -> Self {
Self {
top: value.clone(),
right: value.clone(),
bottom: value.clone(),
left: value,
}
}
}

View File

@@ -1,221 +0,0 @@
use std::ops::{Deref, DerefMut};
use std::rc::Rc;
use gpui::{App, Global, Pixels, SharedString, Window, px};
mod colors;
mod geometry;
mod notification;
mod platform_kind;
mod registry;
mod scale;
mod scrollbar_mode;
mod theme;
pub use colors::*;
pub use geometry::*;
pub use notification::*;
pub use platform_kind::PlatformKind;
pub use registry::*;
pub use scale::*;
pub use scrollbar_mode::*;
pub use theme::*;
/// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
/// Defines window shadow size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
/// Defines window border size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Defines workspace tabbar height
pub const TABBAR_HEIGHT: Pixels = px(28.0);
/// Defines default sidebar width
pub const SIDEBAR_WIDTH: Pixels = px(240.);
pub fn init(cx: &mut App) {
registry::init(cx);
Theme::sync_system_appearance(None, cx);
Theme::sync_scrollbar_appearance(cx);
}
pub trait ActiveTheme {
fn theme(&self) -> &Theme;
}
impl ActiveTheme for App {
#[inline(always)]
fn theme(&self) -> &Theme {
Theme::global(self)
}
}
#[derive(Debug, Clone)]
pub struct Theme {
/// Theme colors
pub colors: ThemeColors,
/// Theme family
pub theme: Rc<ThemeFamily>,
/// The appearance of the theme (light or dark).
pub mode: ThemeMode,
/// The font family for the application.
pub font_family: SharedString,
/// The root font size for the application, default is 15px.
pub font_size: Pixels,
/// Radius for the general elements.
pub radius: Pixels,
/// Radius for the large elements, e.g.: modal, notification.
pub radius_lg: Pixels,
/// Enable shadow for the general elements. default is true
pub shadow: bool,
/// Show the scrollbar mode, default: scrolling
pub scrollbar_mode: ScrollbarMode,
/// Notification settings
pub notification: NotificationSettings,
/// Platform kind
pub platform: PlatformKind,
}
impl Deref for Theme {
type Target = ThemeColors;
fn deref(&self) -> &Self::Target {
&self.colors
}
}
impl DerefMut for Theme {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.colors
}
}
impl Global for Theme {}
impl Theme {
/// Returns the global theme reference
pub fn global(cx: &App) -> &Theme {
cx.global::<Theme>()
}
/// Returns the global theme mutable reference
pub fn global_mut(cx: &mut App) -> &mut Theme {
cx.global_mut::<Theme>()
}
/// Returns true if the theme is dark.
pub fn is_dark(&self) -> bool {
self.mode.is_dark()
}
/// Sync the theme with the system appearance
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
let appearance = window
.as_ref()
.map(|window| window.appearance())
.unwrap_or_else(|| cx.window_appearance());
Self::change(appearance, window, cx);
}
/// Sync the Scrollbar showing behavior with the system
pub fn sync_scrollbar_appearance(cx: &mut App) {
Theme::global_mut(cx).scrollbar_mode = if cx.should_auto_hide_scrollbars() {
ScrollbarMode::Scrolling
} else {
ScrollbarMode::Hover
};
}
/// Apply a new theme to the application.
pub fn apply_theme(new_theme: Rc<ThemeFamily>, window: Option<&mut Window>, cx: &mut App) {
let theme = cx.global_mut::<Theme>();
let mode = theme.mode;
// Update the theme
theme.theme = new_theme;
// Emit a theme change event
Self::change(mode, window, cx);
}
/// Change the app's appearance
pub fn change<M>(mode: M, window: Option<&mut Window>, cx: &mut App)
where
M: Into<ThemeMode>,
{
if !cx.has_global::<Theme>() {
let default_theme = ThemeFamily::default();
let theme = Theme::from(default_theme);
cx.set_global(theme);
}
let mode = mode.into();
let theme = cx.global_mut::<Theme>();
// Set the theme mode
theme.mode = mode;
// Set the theme colors
if mode.is_dark() {
theme.colors = *theme.theme.dark();
} else {
theme.colors = *theme.theme.light();
}
// Refresh the window if available
if let Some(window) = window {
window.refresh();
}
}
}
impl From<ThemeFamily> for Theme {
fn from(family: ThemeFamily) -> Self {
let platform = PlatformKind::platform();
let mode = ThemeMode::default();
// Define the font family based on the platform.
// TODO: Use native fonts on Linux too.
let font_family = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
};
// Define the theme colors based on the appearance
let colors = match mode {
ThemeMode::Light => family.light(),
ThemeMode::Dark => family.dark(),
};
Theme {
font_size: px(15.),
font_family: font_family.into(),
radius: px(6.),
radius_lg: px(10.),
shadow: true,
scrollbar_mode: ScrollbarMode::default(),
notification: NotificationSettings::default(),
mode,
colors: *colors,
theme: Rc::new(family),
platform,
}
}
}

View File

@@ -1,30 +0,0 @@
use gpui::{Anchor, Pixels, px};
use crate::{Edges, TITLEBAR_HEIGHT};
/// The settings for notifications.
#[derive(Debug, Clone)]
pub struct NotificationSettings {
/// The placement of the notification, default: [`Anchor::TopRight`]
pub placement: Anchor,
/// The margins of the notification with respect to the window edges.
pub margins: Edges<Pixels>,
/// The maximum number of notifications to show at once, default: 10
pub max_items: usize,
}
impl Default for NotificationSettings {
fn default() -> Self {
let offset = px(16.);
Self {
placement: Anchor::TopRight,
margins: Edges {
top: TITLEBAR_HEIGHT + offset, // avoid overlap with title bar
right: offset,
bottom: offset,
left: offset,
},
max_items: 10,
}
}
}

View File

@@ -1,33 +0,0 @@
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum PlatformKind {
Mac,
Linux,
Windows,
}
impl PlatformKind {
pub const fn platform() -> Self {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Mac
}
}
#[allow(dead_code)]
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
#[allow(dead_code)]
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
#[allow(dead_code)]
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}
}

View File

@@ -1,70 +0,0 @@
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::{Context as AnyhowContext, Error};
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
use crate::ThemeFamily;
pub fn init(cx: &mut App) {
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
}
struct GlobalThemeRegistry(Entity<ThemeRegistry>);
impl Global for GlobalThemeRegistry {}
pub struct ThemeRegistry {
/// Map of theme names to theme families
themes: HashMap<SharedString, Rc<ThemeFamily>>,
}
impl ThemeRegistry {
/// Retrieve the global theme registry state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalThemeRegistry>().0.clone()
}
/// Set the global theme registry instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalThemeRegistry(state));
}
/// Create a new theme registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let mut themes = HashMap::new();
let asset = cx.asset_source();
if let Ok(paths) = asset.list("themes") {
for path in paths.into_iter() {
match Self::load(&path, asset) {
Ok(theme) => {
themes.insert(path, Rc::new(theme));
}
Err(e) => {
log::error!("Failed to load theme: {path}. Error: {e}");
}
}
}
}
Self { themes }
}
/// Load a theme from the asset source.
fn load(path: &str, asset: &Arc<dyn AssetSource>) -> Result<ThemeFamily, Error> {
// Load the theme file from the assets
let content = asset.load(path)?.context("Theme not found")?;
// Parse the JSON content into a Theme Family struct
let theme: ThemeFamily = serde_json::from_slice(&content)?;
Ok(theme)
}
/// Returns a reference to the map of themes.
pub fn themes(&self) -> &HashMap<SharedString, Rc<ThemeFamily>> {
&self.themes
}
}

View File

@@ -1,281 +0,0 @@
#![allow(dead_code)]
use gpui::{Hsla, SharedString};
/// A collection of colors that are used to style the UI.
///
/// Each step has a semantic meaning, and is used to style different parts of the UI.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub struct ColorScaleStep(usize);
impl ColorScaleStep {
/// All of the steps in a [`ColorScale`].
pub const ALL: [ColorScaleStep; 12] = [
Self::ONE,
Self::TWO,
Self::THREE,
Self::FOUR,
Self::FIVE,
Self::SIX,
Self::SEVEN,
Self::EIGHT,
Self::NINE,
Self::TEN,
Self::ELEVEN,
Self::TWELVE,
];
pub const EIGHT: Self = Self(8);
pub const ELEVEN: Self = Self(11);
pub const FIVE: Self = Self(5);
pub const FOUR: Self = Self(4);
pub const NINE: Self = Self(9);
pub const ONE: Self = Self(1);
pub const SEVEN: Self = Self(7);
pub const SIX: Self = Self(6);
pub const TEN: Self = Self(10);
pub const THREE: Self = Self(3);
pub const TWELVE: Self = Self(12);
pub const TWO: Self = Self(2);
}
/// A scale of colors for a given [`ColorScaleSet`].
///
/// Each [`ColorScale`] contains exactly 12 colors. Refer to
/// [`ColorScaleStep`] for a reference of what each step is used for.
pub struct ColorScale(Vec<Hsla>);
impl FromIterator<Hsla> for ColorScale {
fn from_iter<T: IntoIterator<Item = Hsla>>(iter: T) -> Self {
Self(Vec::from_iter(iter))
}
}
impl ColorScale {
/// Returns the specified step in the [`ColorScale`].
#[inline]
pub fn step(&self, step: ColorScaleStep) -> Hsla {
// Steps are one-based, so we need convert to the zero-based vec index.
self.0[step.0 - 1]
}
/// `Step 1` - Used for main application backgrounds.
///
/// This step provides a neutral base for any overlaying components, ideal for applications' main backdrop or empty spaces such as canvas areas.
///
#[inline]
pub fn step_1(&self) -> Hsla {
self.step(ColorScaleStep::ONE)
}
/// `Step 2` - Used for both main application backgrounds and subtle component backgrounds.
///
/// Like `Step 1`, this step allows variations in background styles, from striped tables, sidebar backgrounds, to card backgrounds.
#[inline]
pub fn step_2(&self) -> Hsla {
self.step(ColorScaleStep::TWO)
}
/// `Step 3` - Used for UI component backgrounds in their normal states.
///
/// This step maintains accessibility by guaranteeing a contrast ratio of 4.5:1 with steps 11 and 12 for text. It could also suit hover states for transparent components.
#[inline]
pub fn step_3(&self) -> Hsla {
self.step(ColorScaleStep::THREE)
}
/// `Step 4` - Used for UI component backgrounds in their hover states.
///
/// Also suited for pressed or selected states of components with a transparent background.
#[inline]
pub fn step_4(&self) -> Hsla {
self.step(ColorScaleStep::FOUR)
}
/// `Step 5` - Used for UI component backgrounds in their pressed or selected states.
#[inline]
pub fn step_5(&self) -> Hsla {
self.step(ColorScaleStep::FIVE)
}
/// `Step 6` - Used for subtle borders on non-interactive components.
///
/// Its usage spans from sidebars' borders, headers' dividers, cards' outlines, to alerts' edges and separators.
#[inline]
pub fn step_6(&self) -> Hsla {
self.step(ColorScaleStep::SIX)
}
/// `Step 7` - Used for subtle borders on interactive components.
///
/// This step subtly delineates the boundary of elements users interact with.
#[inline]
pub fn step_7(&self) -> Hsla {
self.step(ColorScaleStep::SEVEN)
}
/// `Step 8` - Used for stronger borders on interactive components and focus rings.
///
/// It strengthens the visibility and accessibility of active elements and their focus states.
#[inline]
pub fn step_8(&self) -> Hsla {
self.step(ColorScaleStep::EIGHT)
}
/// `Step 9` - Used for solid backgrounds.
///
/// `Step 9` is the most saturated step, having the least mix of white or black.
///
/// Due to its high chroma, `Step 9` is versatile and particularly useful for semantic colors such as
/// error, warning, and success indicators.
#[inline]
pub fn step_9(&self) -> Hsla {
self.step(ColorScaleStep::NINE)
}
/// `Step 10` - Used for hovered or active solid backgrounds, particularly when `Step 9` is their normal state.
///
/// May also be used for extremely low contrast text. This should be used sparingly, as it may be difficult to read.
#[inline]
pub fn step_10(&self) -> Hsla {
self.step(ColorScaleStep::TEN)
}
/// `Step 11` - Used for text and icons requiring low contrast or less emphasis.
#[inline]
pub fn step_11(&self) -> Hsla {
self.step(ColorScaleStep::ELEVEN)
}
/// `Step 12` - Used for text and icons requiring high contrast or prominence.
#[inline]
pub fn step_12(&self) -> Hsla {
self.step(ColorScaleStep::TWELVE)
}
}
pub struct ColorScales {
pub gray: ColorScaleSet,
pub mauve: ColorScaleSet,
pub slate: ColorScaleSet,
pub sage: ColorScaleSet,
pub olive: ColorScaleSet,
pub sand: ColorScaleSet,
pub gold: ColorScaleSet,
pub bronze: ColorScaleSet,
pub brown: ColorScaleSet,
pub yellow: ColorScaleSet,
pub amber: ColorScaleSet,
pub orange: ColorScaleSet,
pub tomato: ColorScaleSet,
pub red: ColorScaleSet,
pub ruby: ColorScaleSet,
pub crimson: ColorScaleSet,
pub pink: ColorScaleSet,
pub plum: ColorScaleSet,
pub purple: ColorScaleSet,
pub violet: ColorScaleSet,
pub iris: ColorScaleSet,
pub indigo: ColorScaleSet,
pub blue: ColorScaleSet,
pub cyan: ColorScaleSet,
pub teal: ColorScaleSet,
pub jade: ColorScaleSet,
pub green: ColorScaleSet,
pub grass: ColorScaleSet,
pub lime: ColorScaleSet,
pub mint: ColorScaleSet,
pub sky: ColorScaleSet,
pub black: ColorScaleSet,
pub white: ColorScaleSet,
}
impl IntoIterator for ColorScales {
type IntoIter = std::vec::IntoIter<Self::Item>;
type Item = ColorScaleSet;
fn into_iter(self) -> Self::IntoIter {
vec![
self.gray,
self.mauve,
self.slate,
self.sage,
self.olive,
self.sand,
self.gold,
self.bronze,
self.brown,
self.yellow,
self.amber,
self.orange,
self.tomato,
self.red,
self.ruby,
self.crimson,
self.pink,
self.plum,
self.purple,
self.violet,
self.iris,
self.indigo,
self.blue,
self.cyan,
self.teal,
self.jade,
self.green,
self.grass,
self.lime,
self.mint,
self.sky,
self.black,
self.white,
]
.into_iter()
}
}
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale.
pub struct ColorScaleSet {
name: SharedString,
light: ColorScale,
dark: ColorScale,
light_alpha: ColorScale,
dark_alpha: ColorScale,
}
impl ColorScaleSet {
pub fn new(
name: impl Into<SharedString>,
light: ColorScale,
light_alpha: ColorScale,
dark: ColorScale,
dark_alpha: ColorScale,
) -> Self {
Self {
name: name.into(),
light,
light_alpha,
dark,
dark_alpha,
}
}
pub fn name(&self) -> &SharedString {
&self.name
}
pub fn light(&self) -> &ColorScale {
&self.light
}
pub fn light_alpha(&self) -> &ColorScale {
&self.light_alpha
}
pub fn dark(&self) -> &ColorScale {
&self.dark
}
pub fn dark_alpha(&self) -> &ColorScale {
&self.dark_alpha
}
}

View File

@@ -1,24 +0,0 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
pub enum ScrollbarMode {
#[default]
Scrolling,
Hover,
Always,
}
impl ScrollbarMode {
pub fn is_scrolling(&self) -> bool {
matches!(self, Self::Scrolling)
}
pub fn is_hover(&self) -> bool {
matches!(self, Self::Hover)
}
pub fn is_always(&self) -> bool {
matches!(self, Self::Always)
}
}

View File

@@ -1,359 +0,0 @@
use std::path::Path;
use gpui::{SharedString, WindowAppearance};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ThemeColors;
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)]
pub enum ThemeMode {
#[default]
Light,
Dark,
}
impl ThemeMode {
pub fn is_dark(&self) -> bool {
matches!(self, Self::Dark)
}
/// Return theme name: `light`, `dark`.
pub fn name(&self) -> &'static str {
match self {
ThemeMode::Light => "Light",
ThemeMode::Dark => "Dark",
}
}
}
impl From<WindowAppearance> for ThemeMode {
fn from(appearance: WindowAppearance) -> Self {
match appearance {
WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
}
}
}
/// Theme family
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
pub struct ThemeFamily {
/// The unique identifier for the theme.
pub id: String,
/// The name of the theme.
pub name: SharedString,
/// The author of the theme.
pub author: SharedString,
/// The URL of the theme.
pub url: String,
/// The light colors for the theme.
pub light: ThemeColors,
/// The dark colors for the theme.
pub dark: ThemeColors,
}
impl Default for ThemeFamily {
fn default() -> Self {
ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
}
}
impl ThemeFamily {
/// Returns the light colors for the theme.
#[inline(always)]
pub fn light(&self) -> &ThemeColors {
&self.light
}
/// Returns the dark colors for the theme.
#[inline(always)]
pub fn dark(&self) -> &ThemeColors {
&self.dark
}
/// Load a theme family from a JSON file.
///
/// # Arguments
///
/// * `path` - Path to the JSON file containing the theme family. This can be
/// an absolute path or a path relative to the current working directory.
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Load from a relative path
/// let theme = ThemeFamily::from_file("assets/themes/my-theme.json")?;
///
/// // Load from an absolute path
/// let theme = ThemeFamily::from_file("/path/to/themes/my-theme.json")?;
/// # Ok(())
/// # }
/// ```
pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let json_data = std::fs::read(path)?;
let theme_family = serde_json::from_slice(&json_data)?;
Ok(theme_family)
}
/// Load a theme family from a JSON file in the assets/themes directory.
///
/// This function looks for the file at `assets/themes/{name}.json` relative
/// to the current working directory. This is useful for loading themes
/// from the standard theme directory in the project structure.
///
/// # Arguments
///
/// * `name` - Name of the theme file (without the .json extension)
///
/// # Returns
///
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
///
/// # Errors
///
/// This function will return an error if:
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
/// - The file contains invalid JSON
/// - The JSON structure doesn't match the `ThemeFamily` schema
///
/// # Example
///
/// ```no_run
/// use theme::ThemeFamily;
///
/// # fn main() -> anyhow::Result<()> {
/// // Assuming the file exists at `assets/themes/my-theme.json`
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?;
///
/// println!("Loaded theme: {}", theme.name);
/// # Ok(())
/// # }
/// ```
pub fn from_assets(target: &str) -> anyhow::Result<Self> {
let path = format!("assets/{target}");
Self::from_file(path)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn test_from_file() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("test-theme.json");
// Create a minimal valid theme JSON with hex colors
// Using simple hex colors that Hsla can parse
// Note: We need to escape the # characters in the raw string
let json_data = r##"{
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"light": {
"background": "#ffffff",
"surface_background": "#fafafa",
"elevated_surface_background": "#f5f5f5",
"panel_background": "#ffffff",
"overlay": "#0000001a",
"title_bar": "#00000000",
"title_bar_inactive": "#ffffff",
"window_border": "#c7c7cf",
"border": "#dbdbdb",
"border_variant": "#d1d1d1",
"border_focused": "#3366cc",
"border_selected": "#3366cc",
"border_transparent": "#00000000",
"border_disabled": "#e6e6e6",
"ring": "#4d79d6",
"text": "#1a1a1a",
"text_muted": "#4d4d4d",
"text_placeholder": "#808080",
"text_accent": "#3366cc",
"icon": "#4d4d4d",
"icon_muted": "#808080",
"icon_accent": "#3366cc",
"element_foreground": "#ffffff",
"element_background": "#3366cc",
"element_hover": "#3366cce6",
"element_active": "#2e5cb8",
"element_selected": "#2952a3",
"element_disabled": "#3366cc4d",
"secondary_foreground": "#2952a3",
"secondary_background": "#e6ecf5",
"secondary_hover": "#3366cc1a",
"secondary_active": "#d9e2f0",
"secondary_selected": "#d9e2f0",
"secondary_disabled": "#3366cc4d",
"danger_foreground": "#ffffff",
"danger_background": "#f5e6e6",
"danger_hover": "#cc33331a",
"danger_active": "#f0d9d9",
"danger_selected": "#f0d9d9",
"danger_disabled": "#cc33334d",
"warning_foreground": "#1a1a1a",
"warning_background": "#f5f0e6",
"warning_hover": "#cc99331a",
"warning_active": "#f0ead9",
"warning_selected": "#f0ead9",
"warning_disabled": "#cc99334d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#e6e6e6",
"ghost_element_hover": "#0000001a",
"ghost_element_active": "#d9d9d9",
"ghost_element_selected": "#d9d9d9",
"ghost_element_disabled": "#0000000d",
"tab_inactive_background": "#e6e6e6",
"tab_hover_background": "#e0e0e0",
"tab_active_background": "#d9d9d9",
"scrollbar_thumb_background": "#00000033",
"scrollbar_thumb_hover_background": "#0000004d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#d9d9d9",
"drop_target_background": "#3366cc1a",
"cursor": "#3399ff",
"selection": "#3399ff40"
},
"dark": {
"background": "#1a1a1a",
"surface_background": "#1f1f1f",
"elevated_surface_background": "#242424",
"panel_background": "#262626",
"overlay": "#ffffff1a",
"title_bar": "#00000000",
"title_bar_inactive": "#1a1a1a",
"window_border": "#404046",
"border": "#404040",
"border_variant": "#383838",
"border_focused": "#4d79d6",
"border_selected": "#4d79d6",
"border_transparent": "#00000000",
"border_disabled": "#2e2e2e",
"ring": "#668cdf",
"text": "#f2f2f2",
"text_muted": "#b3b3b3",
"text_placeholder": "#808080",
"text_accent": "#668cdf",
"icon": "#b3b3b3",
"icon_muted": "#808080",
"icon_accent": "#668cdf",
"element_foreground": "#ffffff",
"element_background": "#4d79d6",
"element_hover": "#4d79d6e6",
"element_active": "#456dc1",
"element_selected": "#3e62ac",
"element_disabled": "#4d79d64d",
"secondary_foreground": "#3e62ac",
"secondary_background": "#2a3652",
"secondary_hover": "#4d79d61a",
"secondary_active": "#303d5c",
"secondary_selected": "#303d5c",
"secondary_disabled": "#4d79d64d",
"danger_foreground": "#ffffff",
"danger_background": "#522a2a",
"danger_hover": "#d64d4d1a",
"danger_active": "#5c3030",
"danger_selected": "#5c3030",
"danger_disabled": "#d64d4d4d",
"warning_foreground": "#f2f2f2",
"warning_background": "#52482a",
"warning_hover": "#d6b34d1a",
"warning_active": "#5c5430",
"warning_selected": "#5c5430",
"warning_disabled": "#d6b34d4d",
"ghost_element_background": "#00000000",
"ghost_element_background_alt": "#2e2e2e",
"ghost_element_hover": "#ffffff1a",
"ghost_element_active": "#383838",
"ghost_element_selected": "#383838",
"ghost_element_disabled": "#ffffff0d",
"tab_inactive_background": "#2e2e2e",
"tab_hover_background": "#333333",
"tab_active_background": "#383838",
"scrollbar_thumb_background": "#ffffff33",
"scrollbar_thumb_hover_background": "#ffffff4d",
"scrollbar_thumb_border": "#00000000",
"scrollbar_track_background": "#00000000",
"scrollbar_track_border": "#383838",
"drop_target_background": "#4d79d61a",
"cursor": "#4db3ff",
"selection": "#4db3ff40"
}
}"##;
// Write the JSON to the file
fs::write(&file_path, json_data).unwrap();
// Test loading the theme from file
let theme = ThemeFamily::from_file(&file_path).unwrap();
// Verify the loaded theme
assert_eq!(theme.id, "test-theme");
assert_eq!(theme.name, "Test Theme");
// Clean up
dir.close().unwrap();
}
#[test]
fn test_from_file_nonexistent() {
// Test that loading a non-existent file returns an error
let result = ThemeFamily::from_file("non-existent-file.json");
assert!(result.is_err());
}
#[test]
fn test_from_file_invalid_json() {
// Create a temporary directory for our test
let dir = tempdir().unwrap();
let file_path = dir.path().join("invalid-theme.json");
// Write invalid JSON
fs::write(&file_path, "invalid json").unwrap();
// Test that loading invalid JSON returns an error
let result = ThemeFamily::from_file(&file_path);
assert!(result.is_err());
// Clean up
dir.close().unwrap();
}
}

View File

@@ -6,11 +6,11 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
gpui.workspace = true
gpui-component.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true
smallvec.workspace = true
anyhow.workspace = true

View File

@@ -5,10 +5,10 @@ use gpui::{
AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement,
Pixels, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, px,
};
use gpui_component::{ActiveTheme, h_flex};
use smallvec::{SmallVec, smallvec};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, PlatformKind};
use ui::h_flex;
use crate::platforms::PlatformKind;
#[cfg(target_os = "linux")]
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::mac::TRAFFIC_LIGHT_PADDING;
@@ -16,6 +16,18 @@ use crate::platforms::windows::WindowsWindowControls;
mod platforms;
/// Defines window border radius for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
/// Defines window shadow size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
/// Defines window border size for platforms that use client side decorations.
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
/// Defines window titlebar height
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
/// Titlebar
pub struct TitleBar {
/// Children elements of the title bar.
@@ -23,6 +35,9 @@ pub struct TitleBar {
/// Whether the title bar is currently being moved.
should_move: bool,
/// Current user platform.
platform: PlatformKind,
}
impl TitleBar {
@@ -30,6 +45,7 @@ impl TitleBar {
Self {
children: smallvec![],
should_move: false,
platform: PlatformKind::platform(),
}
}
@@ -48,7 +64,7 @@ impl TitleBar {
if window.is_window_active() && !self.should_move {
cx.theme().title_bar
} else {
cx.theme().title_bar_inactive
cx.theme().title_bar.alpha(0.75)
}
} else {
cx.theme().title_bar
@@ -86,7 +102,7 @@ impl Render for TitleBar {
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
} else if self.platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
@@ -111,24 +127,24 @@ impl Render for TitleBar {
.id("title-bar")
.justify_between()
.w_full()
.when(cx.theme().platform.is_mac(), |this| {
.when(self.platform.is_mac(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.titlebar_double_click();
}
})
})
.when(cx.theme().platform.is_linux(), |this| {
.when(self.platform.is_linux(), |this| {
this.on_click(|event, window, _| {
if event.click_count() == 2 {
window.zoom_window();
}
})
})
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.when(!self.platform.is_mac(), |this| this.pr_2())
.children(children),
)
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
.when(!window.is_fullscreen(), |this| match self.platform {
PlatformKind::Linux => {
#[cfg(target_os = "linux")]
if matches!(decorations, Decorations::Client { .. }) {

View File

@@ -3,12 +3,12 @@ use std::sync::OnceLock;
use gpui::prelude::FluentBuilder;
use gpui::{
svg, Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window,
Action, App, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, svg,
};
use linicon::{lookup_icon, IconType};
use linicon::{IconType, lookup_icon};
use theme::ActiveTheme;
use ui::{h_flex, Icon, IconName, Sizable};
use ui::{Icon, IconName, Sizable, h_flex};
#[derive(IntoElement)]
pub struct LinuxWindowControls {
@@ -34,20 +34,20 @@ impl RenderOnce for LinuxWindowControls {
.when(supported_controls.minimize, |this| {
this.child(WindowControl::new(
LinuxControl::Minimize,
IconName::WindowMinimize,
CoopIcon::WindowMinimize,
))
})
.when(supported_controls.maximize, |this| {
this.child({
if window.is_maximized() {
WindowControl::new(LinuxControl::Restore, IconName::WindowRestore)
WindowControl::new(LinuxControl::Restore, CoopIcon::WindowRestore)
} else {
WindowControl::new(LinuxControl::Maximize, IconName::WindowMaximize)
WindowControl::new(LinuxControl::Maximize, CoopIcon::WindowMaximize)
}
})
})
.child(
WindowControl::new(LinuxControl::Close, IconName::WindowClose)
WindowControl::new(LinuxControl::Close, CoopIcon::WindowClose)
.when_some(self.close_window_action, |this, close_action| {
this.close_action(close_action)
}),

View File

@@ -2,3 +2,37 @@
pub mod linux;
pub mod mac;
pub mod windows;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum PlatformKind {
Mac,
Linux,
Windows,
}
impl PlatformKind {
pub const fn platform() -> Self {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Mac
}
}
#[allow(dead_code)]
pub fn is_linux(&self) -> bool {
matches!(self, Self::Linux)
}
#[allow(dead_code)]
pub fn is_windows(&self) -> bool {
matches!(self, Self::Windows)
}
#[allow(dead_code)]
pub fn is_mac(&self) -> bool {
matches!(self, Self::Mac)
}
}

View File

@@ -1,10 +1,9 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels,
RenderOnce, Rgba, StatefulInteractiveElement, Styled, Window, WindowControlArea,
App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, Rgba,
StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
};
use theme::ActiveTheme;
use ui::h_flex;
use gpui_component::{ActiveTheme, h_flex};
#[derive(IntoElement)]
pub struct WindowsWindowControls {
@@ -45,8 +44,8 @@ impl RenderOnce for WindowsWindowControls {
a: 1.0,
};
let button_hover_color = cx.theme().ghost_element_hover;
let button_active_color = cx.theme().ghost_element_active;
let button_hover_color = cx.theme().transparent;
let button_active_color = cx.theme().secondary_active;
div()
.id("windows-window-controls")

View File

@@ -1,26 +0,0 @@
[package]
name = "ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
gpui.workspace = true
smol.workspace = true
serde.workspace = true
serde_json.workspace = true
smallvec.workspace = true
anyhow.workspace = true
itertools.workspace = true
log.workspace = true
unicode-segmentation = "1.12.0"
uuid = "1.10"
regex = "1"
image = "0.25.1"
lsp-types = "0.97.0"
rope = { git = "https://github.com/zed-industries/zed" }
sum_tree = { git = "https://github.com/zed-industries/zed" }

View File

@@ -1,191 +0,0 @@
Copyright 2024 Longbridge <https://longbridge.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -1,12 +0,0 @@
use gpui::{actions, Action};
use serde::Deserialize;
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)]
pub struct Confirm {
/// Is confirm with secondary.
pub secondary: bool,
}
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);

View File

@@ -1,18 +0,0 @@
/// A cubic bezier function like CSS `cubic-bezier`.
///
/// Builder:
///
/// https://cubic-bezier.com
pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
move |t: f32| {
let one_t = 1.0 - t;
let one_t2 = one_t * one_t;
let t2 = t * t;
let t3 = t2 * t;
// The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1
let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3;
3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3
}
}

View File

@@ -1,143 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
};
use theme::ActiveTheme;
use crate::{Selectable, Sizable, Size};
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
Size::Small => px(24.).into(),
Size::XSmall => px(20.).into(),
Size::Size(size) => size.into(),
}
}
/// An element that renders a user avatar with customizable appearance options.
///
/// # Examples
///
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
base: Div,
image: Img,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
selected: bool,
}
impl Avatar {
/// Creates a new avatar element with the specified image source.
pub fn new(src: impl Into<ImageSource>) -> Self {
Avatar {
base: div(),
image: img(src),
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
selected: false,
}
}
/// Applies a grayscale filter to the avatar image.
///
/// # Examples
///
/// ```
/// use ui::{Avatar, AvatarShape};
///
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
/// ```
pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale);
self
}
/// Sets the border color of the avatar.
///
/// This might be used to match the border to the background color of
/// the parent element to create the illusion of cropping another
/// shape underneath (for example in face piles.)
pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
self.border_color = Some(color.into());
self
}
}
impl Sizable for Avatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for Avatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Selectable for Avatar {
fn is_selected(&self) -> bool {
self.selected
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl InteractiveElement for Avatar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
px(2.)
} else {
px(0.)
};
let image_size = avatar_size(self.size);
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
div()
.flex_shrink_0()
.size(container_size)
.rounded_full()
.overflow_hidden()
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.child(
self.image
.size(image_size)
.rounded_full()
.object_fit(gpui::ObjectFit::Fill)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
.size(image_size)
.rounded_full()
.into_any_element()
}),
)
}
}

View File

@@ -1,626 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
color: Hsla,
foreground: Hsla,
hover: Hsla,
active: Hsla,
}
impl ButtonCustomVariant {
pub fn new(_window: &Window, cx: &App) -> Self {
Self {
color: cx.theme().element_background,
foreground: cx.theme().element_foreground,
hover: cx.theme().element_hover,
active: cx.theme().element_active,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = color;
self
}
pub fn foreground(mut self, color: Hsla) -> Self {
self.foreground = color;
self
}
pub fn hover(mut self, color: Hsla) -> Self {
self.hover = color;
self
}
pub fn active(mut self, color: Hsla) -> Self {
self.active = color;
self
}
}
/// The variant of the Button.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Danger,
Warning,
Ghost {
alt: bool,
},
Transparent,
Custom(ButtonCustomVariant),
}
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
/// With the primary style for the Button.
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
/// With the secondary style for the Button.
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the warning style for the Button.
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
/// A Button element.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct Button {
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
icon: Option<Icon>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
children: Vec<AnyElement>,
variant: ButtonVariant,
size: Size,
disabled: bool,
loading: bool,
rounded: bool,
compact: bool,
caret: bool,
indicator: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize,
tab_stop: bool,
pub(crate) selected: bool,
}
impl From<Button> for AnyElement {
fn from(button: Button) -> Self {
button.into_any_element()
}
}
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
id: id.clone(),
base: div().flex_shrink_0().id(id),
style: StyleRefinement::default(),
icon: None,
label: None,
variant: ButtonVariant::default(),
disabled: false,
selected: false,
indicator: false,
compact: false,
caret: false,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
on_hover: None,
loading: false,
children: Vec::new(),
tab_index: 0,
tab_stop: true,
}
}
/// Make the button rounded.
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
}
/// Set label to the Button, if no label is set, the button will be in Icon Button mode.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the tooltip of the button.
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
/// Set true to show the loading indicator.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Set true to make the button compact (no padding).
pub fn compact(mut self) -> Self {
self.compact = true;
self
}
/// Set true to show the caret indicator.
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
/// Set true to show the indicator.
pub fn indicator(mut self) -> Self {
self.indicator = true;
self
}
/// Add click handler.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
/// Add hover handler, the bool parameter indicates whether the mouse is hovering.
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Rc::new(handler));
self
}
/// Set the tab index of the button, it will be used to focus the button by tab key.
///
/// Default is 0.
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
///
/// Default is true.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
#[inline]
fn clickable(&self) -> bool {
!(self.disabled || self.loading) && self.on_click.is_some()
}
#[inline]
fn hoverable(&self) -> bool {
!(self.disabled || self.loading) && self.on_hover.is_some()
}
}
impl Disableable for Button {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Button {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Sizable for Button {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ButtonVariants for Button {
fn with_variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
}
impl Styled for Button {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for Button {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements)
}
}
impl InteractiveElement for Button {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Button {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let clickable = self.clickable();
let hoverable = self.hoverable();
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Large => Size::Medium,
_ => self.size,
};
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
.read(cx)
.clone();
self.base
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_index(self.tab_index)
.tab_stop(self.tab_stop),
)
})
.relative()
.flex_shrink_0()
.flex()
.items_center()
.justify_center()
.cursor_default()
.overflow_hidden()
.refine_style(&self.style)
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
})
.when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
match self.size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Medium => this.size_7(),
_ => this.size_9(),
}
} else {
// Normal Button
match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => {
if self.icon.is_some() {
this.h_6().pl_2().pr_2p5()
} else {
this.h_6().px_2()
}
}
Size::Small => {
if self.icon.is_some() {
this.h_7().pl_2().pr_2p5()
} else {
this.h_7().px_2()
}
}
Size::Medium => {
if self.icon.is_some() {
this.h_8().pl_3().pr_3p5()
} else {
this.h_8().px_3()
}
}
Size::Large => {
if self.icon.is_some() {
this.h_10().px_3().pr_3p5()
} else {
this.h_10().px_3()
}
}
}
}
})
.refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if self.disabled {
cx.stop_propagation();
return;
}
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_click, |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
})
})
.child({
h_flex()
.id("label")
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.text_xs().gap_1(),
Size::Small => this.text_sm().gap_1p5(),
_ => this.text_sm().gap_2(),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading, |this| this.child(Indicator::new()))
.when_some(self.label, |this, label| {
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.children(self.children)
.when(self.caret, |this| {
this.justify_between().gap_0p5().child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().text_muted),
)
})
})
.text_color(normal_style.fg)
.when(self.indicator && !self.disabled, |this| {
this.child(
div()
.absolute()
.bottom_px()
.right_px()
.size_1()
.rounded_full()
.bg(gpui::green()),
)
})
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
let hover_style = style.hovered(cx);
this.bg(hover_style.bg).text_color(hover_style.fg)
})
.active(|this| {
let active_style = style.active(cx);
this.bg(active_style.bg).text_color(active_style.fg)
})
})
.when(self.selected, |this| {
let selected_style = style.selected(cx);
this.bg(selected_style.bg).text_color(selected_style.fg)
})
.when(self.disabled, |this| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
})
.when(self.loading && !self.disabled, |this| {
this.bg(normal_style.bg.opacity(0.8))
.text_color(normal_style.fg.opacity(0.8))
})
.when_some(self.tooltip.clone(), |this, tooltip| {
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into())
})
}
}
struct ButtonVariantStyle {
bg: Hsla,
fg: Hsla,
}
impl ButtonVariant {
fn normal(&self, cx: &App) -> ButtonVariantStyle {
let bg = self.bg_color(cx);
let fg = self.text_color(cx);
ButtonVariantStyle { bg, fg }
}
fn bg_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_background,
ButtonVariant::Secondary => cx.theme().secondary_background,
ButtonVariant::Danger => cx.theme().danger_background,
ButtonVariant::Warning => cx.theme().warning_background,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().ghost_element_background_alt
} else {
cx.theme().ghost_element_background
}
}
ButtonVariant::Custom(colors) => colors.color,
_ => gpui::transparent_black(),
}
}
fn text_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_foreground,
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Danger => cx.theme().danger_foreground,
ButtonVariant::Warning => cx.theme().warning_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().text
} else {
cx.theme().text_muted
}
}
ButtonVariant::Custom(colors) => colors.foreground,
}
}
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_hover,
ButtonVariant::Secondary => cx.theme().secondary_hover,
ButtonVariant::Danger => cx.theme().danger_hover,
ButtonVariant::Warning => cx.theme().warning_hover,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_hover,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.hover,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn active(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().secondary_active,
ButtonVariant::Danger => cx.theme().danger_active,
ButtonVariant::Warning => cx.theme().warning_active,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn selected(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_selected,
ButtonVariant::Secondary => cx.theme().secondary_selected,
ButtonVariant::Danger => cx.theme().danger_selected,
ButtonVariant::Warning => cx.theme().warning_selected,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_selected,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
_ => self.text_color(cx),
};
ButtonVariantStyle { bg, fg }
}
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Danger => cx.theme().danger_disabled,
ButtonVariant::Warning => cx.theme().warning_disabled,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_disabled,
ButtonVariant::Secondary => cx.theme().secondary_disabled,
_ => cx.theme().element_disabled,
};
let fg = match self {
ButtonVariant::Primary => cx.theme().text_muted, // TODO: use a different color?
_ => cx.theme().text_muted,
};
ButtonVariantStyle { bg, fg }
}
}

View File

@@ -1,312 +0,0 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::icon::IconNamed;
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
/// A Checkbox element.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct Checkbox {
id: ElementId,
base: Div,
style: StyleRefinement,
label: Option<SharedString>,
children: Vec<AnyElement>,
checked: bool,
disabled: bool,
size: Size,
tab_stop: bool,
tab_index: isize,
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
}
impl Checkbox {
/// Create a new Checkbox with the given id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
style: StyleRefinement::default(),
label: None,
children: Vec::new(),
checked: false,
disabled: false,
size: Size::default(),
on_click: None,
tab_stop: true,
tab_index: 0,
}
}
/// Set the label for the checkbox.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the checked state for the checkbox.
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Set the click handler for the checkbox.
///
/// The `&bool` parameter indicates the new checked state after the click.
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
/// Set the tab stop for the checkbox, default is true.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
/// Set the tab index for the checkbox, default is 0.
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = tab_index;
self
}
#[allow(clippy::type_complexity)]
fn handle_click(
on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
checked: bool,
window: &mut Window,
cx: &mut App,
) {
let new_checked = !checked;
if let Some(f) = on_click {
(f)(&new_checked, window, cx);
}
}
}
impl InteractiveElement for Checkbox {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Checkbox {}
impl Styled for Checkbox {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl Disableable for Checkbox {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Checkbox {
fn selected(self, selected: bool) -> Self {
self.checked(selected)
}
fn is_selected(&self) -> bool {
self.checked
}
}
impl ParentElement for Checkbox {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Sizable for Checkbox {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
pub(crate) fn checkbox_check_icon(
id: ElementId,
size: Size,
checked: bool,
disabled: bool,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let toggle_state = window.use_keyed_state(id, cx, |_, _| checked);
let color = if disabled {
cx.theme().text.opacity(0.5)
} else {
cx.theme().text
};
svg()
.absolute()
.top_px()
.left_px()
.map(|this| match size {
Size::XSmall => this.size_2(),
Size::Small => this.size_2p5(),
Size::Medium => this.size_3(),
Size::Large => this.size_3p5(),
_ => this.size_3(),
})
.text_color(color)
.map(|this| match checked {
true => this.path(IconName::Check.path()),
_ => this,
})
.map(|this| {
if !disabled && checked != *toggle_state.read(cx) {
let duration = Duration::from_secs_f64(0.25);
cx.spawn({
let toggle_state = toggle_state.clone();
async move |cx| {
cx.background_executor().timer(duration).await;
toggle_state.update(cx, |this, _| *this = checked);
}
})
.detach();
this.with_animation(
ElementId::NamedInteger("toggle".into(), checked as u64),
Animation::new(Duration::from_secs_f64(0.25)),
move |this, delta| {
this.opacity(if checked { 1.0 * delta } else { 1.0 - delta })
},
)
.into_any_element()
} else {
this.into_any_element()
}
})
}
impl RenderOnce for Checkbox {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
.read(cx)
.clone();
let checked = self.checked;
let radius = cx.theme().radius.min(px(4.));
let border_color = if checked {
cx.theme().border_focused
} else {
cx.theme().border
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
div().child(
self.base
.id(self.id.clone())
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_stop(self.tab_stop)
.tab_index(self.tab_index),
)
})
.h_flex()
.gap_2()
.items_start()
.line_height(relative(1.))
.text_color(cx.theme().text)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Small => this.text_sm(),
Size::Medium => this.text_base(),
Size::Large => this.text_lg(),
_ => this,
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.rounded(cx.theme().radius * 0.5)
.refine_style(&self.style)
.child(
div()
.relative()
.map(|this| match self.size {
Size::XSmall => this.size_3(),
Size::Small => this.size_3p5(),
Size::Medium => this.size_4(),
Size::Large => this.size(rems(1.125)),
_ => this.size_4(),
})
.flex_shrink_0()
.border_1()
.border_color(color)
.rounded(radius)
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.map(|this| match checked {
false => this.bg(cx.theme().background),
_ => this.bg(color),
})
.child(checkbox_check_icon(
self.id,
self.size,
checked,
self.disabled,
window,
cx,
)),
)
.when(self.label.is_some() || !self.children.is_empty(), |this| {
this.child(
v_flex()
.w_full()
.line_height(relative(1.2))
.gap_1()
.map(|this| {
if let Some(label) = self.label {
this.child(
div()
.size_full()
.text_color(cx.theme().text)
.when(self.disabled, |this| {
this.text_color(cx.theme().text_muted)
})
.line_height(relative(1.))
.child(label),
)
} else {
this
}
})
.children(self.children),
)
})
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
// Avoid focus on mouse down.
window.prevent_default();
})
.when(!self.disabled, |this| {
this.on_click({
let on_click = self.on_click.clone();
move |_, window, cx| {
window.prevent_default();
Self::handle_click(&on_click, checked, window, cx);
}
})
}),
)
}
}

View File

@@ -1,81 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
};
use theme::ActiveTheme;
/// A divider that can be either vertical or horizontal.
#[derive(IntoElement)]
pub struct Divider {
base: Div,
label: Option<SharedString>,
axis: Axis,
color: Option<Hsla>,
}
impl Divider {
pub fn vertical() -> Self {
Self {
base: div().h_full(),
axis: Axis::Vertical,
label: None,
color: None,
}
}
pub fn horizontal() -> Self {
Self {
base: div().w_full(),
axis: Axis::Horizontal,
label: None,
color: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn color(mut self, color: impl Into<Hsla>) -> Self {
self.color = Some(color.into());
self
}
}
impl Styled for Divider {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Divider {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
self.base
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.child(
div()
.absolute()
.rounded_full()
.map(|this| match self.axis {
Axis::Vertical => this.w(px(1.)).h_full(),
Axis::Horizontal => this.h(px(1.)).w_full(),
})
.bg(self.color.unwrap_or(cx.theme().border_variant)),
)
.when_some(self.label, |this, label| {
this.child(
div()
.px_2()
.py_1()
.mx_auto()
.text_xs()
.bg(cx.theme().background)
.child(label),
)
})
}
}

View File

@@ -1,436 +0,0 @@
use std::ops::Deref;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use super::{DockArea, DockItem};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle};
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockPlacement {
Center,
Left,
Bottom,
Right,
}
impl DockPlacement {
fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
Self::Center => unreachable!(),
}
}
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
pub fn is_bottom(&self) -> bool {
matches!(self, Self::Bottom)
}
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
///
/// This is unlike Panel, it can't be move or add any other panel.
pub struct Dock {
pub(super) placement: DockPlacement,
dock_area: WeakEntity<DockArea>,
/// Dock layout
pub(crate) panel: DockItem,
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
pub(super) size: Pixels,
/// Whether the Dock is open
pub(super) open: bool,
/// Whether the Dock is collapsible, default: true
pub(super) collapsible: bool,
/// Whether the Dock is resizing
resizing: bool,
}
impl Dock {
pub(crate) fn new(
dock_area: WeakEntity<DockArea>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = cx.new(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
tab.closable = true;
tab
});
let panel = DockItem::Tabs {
items: Vec::new(),
active_ix: 0,
view: panel.clone(),
};
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
Self {
placement,
dock_area,
panel,
open: true,
collapsible: true,
size: px(200.0),
resizing: false,
}
}
pub fn left(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Left, window, cx)
}
pub fn bottom(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Bottom, window, cx)
}
pub fn right(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Right, window, cx)
}
/// Update the Dock to be collapsible or not.
///
/// And if the Dock is not collapsible, it will be open.
pub fn set_collapsible(
&mut self,
collapsible: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.collapsible = collapsible;
if !collapsible {
self.open = true
}
cx.notify();
}
fn subscribe_panel_events(
dock_area: WeakEntity<DockArea>,
panel: &DockItem,
window: &mut Window,
cx: &mut App,
) {
match panel {
DockItem::Tabs { view, .. } => {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Split { items, view, .. } => {
for item in items {
Self::subscribe_panel_events(dock_area.clone(), item, window, cx);
}
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
pub fn set_panel(&mut self, panel: DockItem, _window: &mut Window, cx: &mut Context<Self>) {
self.panel = panel;
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_open(!self.open, window, cx);
}
/// Returns the size of the Dock, the size is means the width or height of
/// the Dock, if the placement is left or right, the size is width,
/// otherwise the size is height.
pub fn size(&self) -> Pixels {
self.size
}
/// Set the size of the Dock.
pub fn set_size(&mut self, size: Pixels, _window: &mut Window, cx: &mut Context<Self>) {
self.size = size.max(PANEL_MIN_SIZE);
cx.notify();
}
/// Set the open state of the Dock.
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
cx.notify();
}
/// Add item to the Dock.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.panel.add_panel(panel, &self.dock_area, window, cx);
cx.notify();
}
fn render_resize_handle(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let axis = self.placement.axis();
let view = cx.entity().clone();
resize_handle("resize-handle", axis)
.placement(self.placement)
.on_drag(ResizePanel {}, move |info, _, _, cx| {
cx.stop_propagation();
view.update(cx, |view, _cx| {
view.resizing = true;
});
cx.new(|_| info.deref().clone())
})
}
fn resize(
&mut self,
mouse_position: Point<Pixels>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.resizing {
return;
}
let dock_area = self
.dock_area
.upgrade()
.expect("DockArea is missing")
.read(cx);
let area_bounds = dock_area.bounds;
let mut left_dock_size = px(0.0);
let mut right_dock_size = px(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock
&& left_dock.entity_id() != cx.entity().entity_id()
{
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock
&& right_dock.entity_id() != cx.entity().entity_id()
{
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
let size = match self.placement {
DockPlacement::Left => mouse_position.x - area_bounds.left(),
DockPlacement::Right => area_bounds.right() - mouse_position.x,
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
DockPlacement::Center => unreachable!(),
};
match self.placement {
DockPlacement::Left => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Right => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Bottom => {
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Center => unreachable!(),
}
cx.notify();
}
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.resizing = false;
}
}
impl Render for Dock {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
if !self.open && !self.placement.is_bottom() {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
.map(|this| match self.placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
DockPlacement::Bottom => this.w_full().h(self.size),
DockPlacement::Center => unreachable!(),
})
// Bottom Dock should keep the title bar, then user can click the Toggle button
.when(!self.open && self.placement.is_bottom(), |this| {
this.h(px(29.))
})
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
view: cx.entity().clone(),
})
}
}
struct DockElement {
view: Entity<Dock>,
}
impl IntoElement for DockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockElement {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut gpui::Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut gpui::Window,
cx: &mut App,
) {
window.on_mouse_event({
let view = self.view.clone();
let is_resizing = view.read(cx).resizing;
move |e: &MouseMoveEvent, phase, window, cx| {
if !is_resizing {
return;
}
if !phase.bubble() {
return;
}
view.update(cx, |view, cx| view.resize(e.position, window, cx))
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx));
}
}
})
}
}

View File

@@ -1,807 +0,0 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, Edges, Entity,
EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _,
Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, px,
};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use crate::ElementExt;
#[allow(clippy::module_inception)]
mod dock;
mod panel;
mod stack_panel;
mod tab_panel;
pub use dock::*;
pub use panel::*;
pub use stack_panel::*;
pub use tab_panel::*;
actions!(dock, [ToggleZoom, ClosePanel]);
pub enum DockEvent {
/// The layout of the dock has changed, subscribers this to save the layout.
///
/// This event is emitted when every time the layout of the dock has changed,
/// So it emits may be too frequently, you may want to debounce the event.
LayoutChanged,
}
/// The main area of the dock.
pub struct DockArea {
pub(crate) bounds: Bounds<Pixels>,
/// The center view of the dockarea.
pub items: DockItem,
/// The left dock of the dock_area.
left_dock: Option<Entity<Dock>>,
/// The bottom dock of the dock_area.
bottom_dock: Option<Entity<Dock>>,
/// The right dock of the dock_area.
right_dock: Option<Entity<Dock>>,
/// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed,
toggle_button_panels: Edges<Option<EntityId>>,
/// Whether to show the toggle button.
toggle_button_visible: bool,
/// The top zoom view of the dock_area, if any.
zoom_view: Option<AnyView>,
/// Lock panels layout, but allow to resize.
is_locked: bool,
/// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default).
pub(crate) panel_style: PanelStyle,
subscriptions: Vec<Subscription>,
}
/// DockItem is a tree structure that represents the layout of the dock.
#[derive(Clone)]
pub enum DockItem {
/// Split layout
Split {
axis: Axis,
items: Vec<DockItem>,
sizes: Vec<Option<Pixels>>,
view: Entity<StackPanel>,
},
/// Tab layout
Tabs {
items: Vec<Arc<dyn PanelView>>,
active_ix: usize,
view: Entity<TabPanel>,
},
/// Single panel layout
Panel { view: Arc<dyn PanelView> },
}
impl DockItem {
/// Create DockItem with split layout, each item of panel have equal size.
pub fn split(
axis: Axis,
items: Vec<DockItem>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Self {
let sizes = vec![None; items.len()];
Self::split_with_sizes(axis, items, sizes, dock_area, window, cx)
}
/// Create DockItem with split layout, each item of panel have specified size.
///
/// Please note that the `items` and `sizes` must have the same length.
/// Set `None` in `sizes` to make the index of panel have auto size.
pub fn split_with_sizes(
axis: Axis,
items: Vec<DockItem>,
sizes: Vec<Option<Pixels>>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Self {
let mut items = items;
let stack_panel = cx.new(|cx| {
let mut stack_panel = StackPanel::new(axis, window, cx);
for (i, item) in items.iter_mut().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
}
for (i, item) in items.iter().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), window, cx)
}
stack_panel
});
window.defer(cx, {
let stack_panel = stack_panel.clone();
let dock_area = dock_area.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&stack_panel, window, cx);
});
}
});
Self::Split {
axis,
items,
sizes,
view: stack_panel,
}
}
/// Create DockItem with panel layout
pub fn panel(panel: Arc<dyn PanelView>) -> Self {
Self::Panel { view: panel }
}
/// Create DockItem with tabs layout, items are displayed as tabs.
///
/// The `active_ix` is the index of the active tab, if `None` the first tab is active.
pub fn tabs(
items: Vec<Arc<dyn PanelView>>,
active_ix: Option<usize>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Self {
let mut new_items: Vec<Arc<dyn PanelView>> = vec![];
for item in items.into_iter() {
new_items.push(item)
}
Self::new_tabs(new_items, active_ix, dock_area, window, cx)
}
pub fn tab<P: Panel>(
item: Entity<P>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Self {
Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, window, cx)
}
fn new_tabs(
items: Vec<Arc<dyn PanelView>>,
active_ix: Option<usize>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Self {
let active_ix = active_ix.unwrap_or(0);
let tab_panel = cx.new(|cx| {
let mut tab_panel = TabPanel::new(None, dock_area.clone(), window, cx);
for item in items.iter() {
tab_panel.add_panel(item.clone(), window, cx)
}
tab_panel.active_ix = active_ix;
tab_panel
});
Self::Tabs {
items,
active_ix,
view: tab_panel,
}
}
/// Returns all panel ids
pub fn panel_ids(&self, cx: &App) -> Vec<SharedString> {
match self {
Self::Panel { .. } => vec![],
Self::Tabs { view, .. } => view.read(cx).panel_ids(cx),
Self::Split { items, .. } => items
.iter()
.filter_map(|item| match item {
DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)),
_ => None,
})
.flatten()
.collect(),
}
}
/// Returns the views of the dock item.
pub fn view(&self) -> Arc<dyn PanelView> {
match self {
Self::Split { view, .. } => Arc::new(view.clone()),
Self::Tabs { view, .. } => Arc::new(view.clone()),
Self::Panel { view, .. } => view.clone(),
}
}
/// Find existing panel in the dock item.
pub fn find_panel(&self, panel: Arc<dyn PanelView>) -> Option<Arc<dyn PanelView>> {
match self {
Self::Split { items, .. } => {
items.iter().find_map(|item| item.find_panel(panel.clone()))
}
Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(),
Self::Panel { view } => Some(view.clone()),
}
}
/// Add a panel to the dock item.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
dock_area: &WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) {
match self {
Self::Tabs { view, items, .. } => {
items.push(panel.clone());
view.update(cx, |tab_panel, cx| {
tab_panel.add_panel(panel, window, cx);
});
}
Self::Split { view, items, .. } => {
// Iter items to add panel to the first tabs
for item in items.iter_mut() {
if let DockItem::Tabs { view, .. } = item {
view.update(cx, |tab_panel, cx| {
tab_panel.add_panel(panel.clone(), window, cx);
});
return;
}
}
// Unable to find tabs, create new tabs
let new_item = Self::tabs(vec![panel.clone()], None, dock_area, window, cx);
items.push(new_item.clone());
view.update(cx, |stack_panel, cx| {
stack_panel.add_panel(new_item.view(), None, dock_area.clone(), window, cx);
});
}
Self::Panel { .. } => {}
}
}
/// Set the collapsed state of the dock area
pub fn set_collapsed(&self, collapsed: bool, window: &mut Window, cx: &mut App) {
match self {
DockItem::Tabs { view, .. } => {
view.update(cx, |tab_panel, cx| {
tab_panel.set_collapsed(collapsed, window, cx);
});
}
DockItem::Split { items, .. } => {
// For each child item, set collapsed state
for item in items {
item.set_collapsed(collapsed, window, cx);
}
}
DockItem::Panel { .. } => {}
}
}
/// Recursively traverses to find the left-most and top-most TabPanel.
pub(crate) fn left_top_tab_panel(&self, cx: &App) -> Option<Entity<TabPanel>> {
match self {
DockItem::Tabs { view, .. } => Some(view.clone()),
DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx),
DockItem::Panel { .. } => None,
}
}
/// Recursively traverses to find the right-most and top-most TabPanel.
pub(crate) fn right_top_tab_panel(&self, cx: &App) -> Option<Entity<TabPanel>> {
match self {
DockItem::Tabs { view, .. } => Some(view.clone()),
DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx),
DockItem::Panel { .. } => None,
}
}
pub(crate) fn focus_tab_panel(&self, window: &mut Window, cx: &mut App) {
if let DockItem::Tabs { view, .. } = self {
window.focus(&view.read(cx).focus_handle(cx), cx);
}
}
}
impl DockArea {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let stack_panel = cx.new(|cx| StackPanel::new(Axis::Horizontal, window, cx));
let dock_item = DockItem::Split {
axis: Axis::Horizontal,
items: vec![],
sizes: vec![],
view: stack_panel.clone(),
};
let mut this = Self {
bounds: Bounds::default(),
items: dock_item,
zoom_view: None,
toggle_button_panels: Edges::default(),
toggle_button_visible: true,
left_dock: None,
right_dock: None,
bottom_dock: None,
is_locked: false,
panel_style: PanelStyle::Default,
subscriptions: vec![],
};
this.subscribe_panel(&stack_panel, window, cx);
this
}
/// Set the panel style of the dock area.
pub fn style(mut self, style: PanelStyle) -> Self {
self.panel_style = style;
self
}
/// The DockItem as the center of the dock area.
///
/// This is used to render at the Center of the DockArea.
pub fn set_center(&mut self, item: DockItem, window: &mut Window, cx: &mut Context<Self>) {
self.subscribe_item(&item, window, cx);
self.items = item;
self.update_toggle_button_tab_panels(window, cx);
cx.notify();
}
pub fn set_left_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.subscribe_item(&panel, window, cx);
let weak_self = cx.entity().downgrade();
self.left_dock = Some(cx.new(|cx| {
let mut dock = Dock::left(weak_self.clone(), window, cx);
if let Some(size) = size {
dock.set_size(size, window, cx);
}
dock.set_panel(panel, window, cx);
dock.set_open(open, window, cx);
dock
}));
self.update_toggle_button_tab_panels(window, cx);
}
pub fn set_bottom_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.subscribe_item(&panel, window, cx);
let weak_self = cx.entity().downgrade();
self.bottom_dock = Some(cx.new(|cx| {
let mut dock = Dock::bottom(weak_self.clone(), window, cx);
if let Some(size) = size {
dock.set_size(size, window, cx);
}
dock.set_panel(panel, window, cx);
dock.set_open(open, window, cx);
dock
}));
self.update_toggle_button_tab_panels(window, cx);
}
pub fn set_right_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.subscribe_item(&panel, window, cx);
let weak_self = cx.entity().downgrade();
self.right_dock = Some(cx.new(|cx| {
let mut dock = Dock::right(weak_self.clone(), window, cx);
if let Some(size) = size {
dock.set_size(size, window, cx);
}
dock.set_panel(panel, window, cx);
dock.set_open(open, window, cx);
dock
}));
self.update_toggle_button_tab_panels(window, cx);
}
/// Reset all docks
pub fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.left_dock = None;
self.right_dock = None;
self.bottom_dock = None;
cx.notify();
}
/// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels.
pub fn set_locked(&mut self, locked: bool, _window: &mut Window, _cx: &mut App) {
self.is_locked = locked;
}
/// Determine if the dock area is locked.
pub fn is_locked(&self) -> bool {
self.is_locked
}
/// Determine if the dock area has a dock at the given placement.
pub fn has_dock(&self, placement: DockPlacement) -> bool {
match placement {
DockPlacement::Left => self.left_dock.is_some(),
DockPlacement::Bottom => self.bottom_dock.is_some(),
DockPlacement::Right => self.right_dock.is_some(),
DockPlacement::Center => false,
}
}
/// Determine if the dock at the given placement is open.
pub fn is_dock_open(&self, placement: DockPlacement, cx: &App) -> bool {
match placement {
DockPlacement::Left => self
.left_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Bottom => self
.bottom_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Right => self
.right_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Center => false,
}
}
/// Set the dock at the given placement to be open or closed.
///
/// Only the left, bottom, right dock can be toggled.
pub fn set_dock_collapsible(
&mut self,
collapsible_edges: Edges<bool>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(left_dock) = self.left_dock.as_ref() {
left_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.left, window, cx);
});
}
if let Some(bottom_dock) = self.bottom_dock.as_ref() {
bottom_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.bottom, window, cx);
});
}
if let Some(right_dock) = self.right_dock.as_ref() {
right_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.right, window, cx);
});
}
}
/// Determine if the dock at the given placement is collapsible.
pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &App) -> bool {
match placement {
DockPlacement::Left => self
.left_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Bottom => self
.bottom_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Right => self
.right_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Center => false,
}
}
pub fn toggle_dock(
&self,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let dock = match placement {
DockPlacement::Left => &self.left_dock,
DockPlacement::Bottom => &self.bottom_dock,
DockPlacement::Right => &self.right_dock,
DockPlacement::Center => return,
};
if let Some(dock) = dock {
dock.update(cx, |view, cx| {
view.toggle_open(window, cx);
})
}
}
/// Add a panel item to the dock area at the given placement.
///
/// If the left, bottom, right dock is not present, it will set the dock at the placement.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let weak_self = cx.entity().downgrade();
match placement {
DockPlacement::Left => {
if let Some(dock) = self.left_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx))
} else {
self.set_left_dock(
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
Some(px(320.)),
true,
window,
cx,
);
}
}
DockPlacement::Bottom => {
if let Some(dock) = self.bottom_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx))
} else {
self.set_bottom_dock(
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
None,
true,
window,
cx,
);
}
}
DockPlacement::Right => {
self.set_right_dock(
DockItem::tabs(vec![panel], None, &weak_self, window, cx),
Some(px(320.)),
true,
window,
cx,
);
}
DockPlacement::Center => {
self.items
.add_panel(panel, &cx.entity().downgrade(), window, cx);
}
}
}
/// Subscribe event on the panels
fn subscribe_item(&mut self, item: &DockItem, window: &mut Window, cx: &mut Context<Self>) {
match item {
DockItem::Split { items, view, .. } => {
for item in items {
self.subscribe_item(item, window, cx);
}
self.subscriptions.push(cx.subscribe_in(
view,
window,
move |_, _, event, window, cx| {
if let PanelEvent::LayoutChanged = event {
cx.spawn_in(window, async move |view, window| {
_ = view.update_in(window, |view, window, cx| {
view.update_toggle_button_tab_panels(window, cx)
});
})
.detach();
cx.emit(DockEvent::LayoutChanged);
}
},
));
}
DockItem::Tabs { .. } => {
// We subscribe to the tab panel event in StackPanel's insert_panel
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
/// Subscribe zoom event on the panel
pub(crate) fn subscribe_panel<P: Panel>(
&mut self,
view: &Entity<P>,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
let subscription =
cx.subscribe_in(
view,
window,
move |_this, panel, event, window, cx| match event {
PanelEvent::ZoomIn => {
let panel = panel.clone();
cx.spawn_in(window, async move |view, window| {
view.update_in(window, |view, window, cx| {
view.set_zoomed_in(panel, window, cx);
cx.notify();
})
.ok();
})
.detach();
}
PanelEvent::ZoomOut => {
cx.spawn_in(window, async move |view, window| {
_ = view.update_in(window, |view, window, cx| {
view.set_zoomed_out(window, cx);
});
})
.detach();
}
PanelEvent::LayoutChanged => {
cx.spawn_in(window, async move |view, window| {
view.update_in(window, |view, window, cx| {
view.update_toggle_button_tab_panels(window, cx)
})
.ok();
})
.detach();
// Emit layout changed event for dock
cx.emit(DockEvent::LayoutChanged);
}
},
);
self.subscriptions.push(subscription);
}
pub fn set_zoomed_in<P: Panel>(
&mut self,
panel: Entity<P>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.zoom_view = Some(panel.into());
cx.notify();
}
pub fn set_zoomed_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.zoom_view = None;
cx.notify();
}
fn render_items(&self, _window: &mut Window, _cx: &mut Context<Self>) -> AnyElement {
match &self.items {
DockItem::Split { view, .. } => view.clone().into_any_element(),
DockItem::Tabs { view, .. } => view.clone().into_any_element(),
DockItem::Panel { view, .. } => view.clone().view().into_any_element(),
}
}
pub fn update_toggle_button_tab_panels(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// Left toggle button
self.toggle_button_panels.left = self
.items
.left_top_tab_panel(cx)
.map(|view| view.entity_id());
// Right toggle button
self.toggle_button_panels.right = self
.items
.right_top_tab_panel(cx)
.map(|view| view.entity_id());
// Bottom toggle button
self.toggle_button_panels.bottom = self
.bottom_dock
.as_ref()
.and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx))
.map(|view| view.entity_id());
}
pub fn focus_tab_panel(&mut self, window: &mut Window, cx: &mut App) {
self.items.focus_tab_panel(window, cx);
}
}
impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity().clone();
let decorations = window.window_decorations();
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds))
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.map(|this| match decorations {
Decorations::Server => this,
Decorations::Client { tiling } => this
.when(!(tiling.top || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
}),
})
.child(zoom_view)
} else {
// render dock
this.child(
div()
.flex()
.flex_row()
.h_full()
// Left dock
.when_some(self.left_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
})
// Center
.child(
div()
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
// Top center
.child(
div()
.flex_1()
.overflow_hidden()
.child(self.render_items(window, cx)),
)
// Bottom Dock
.when_some(self.bottom_dock.clone(), |this, dock| {
this.child(dock)
}),
)
// Right Dock
.when_some(self.right_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
}),
)
}
})
}
}

View File

@@ -1,158 +0,0 @@
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window,
};
use crate::button::Button;
use crate::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent {
ZoomIn,
ZoomOut,
LayoutChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelStyle {
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
Default,
/// Always display the tab bar.
TabBar,
}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
/// This is used to identify the panel when deserializing the panel.
/// Once you have defined a panel id, this must not be changed.
fn panel_id(&self) -> SharedString;
/// The title of the panel
fn title(&self, _cx: &App) -> AnyElement {
SharedString::from("Unnamed").into_any()
}
/// Whether the panel can be closed, default is `true`.
fn closable(&self, _cx: &App) -> bool {
true
}
/// Return true if the panel is zoomable, default is `false`.
fn zoomable(&self, _cx: &App) -> bool {
true
}
/// Return false to hide panel, true to show panel, default is `true`.
///
/// This method called in Panel render, we should make sure it is fast.
fn visible(&self, _cx: &App) -> bool {
true
}
/// Set active state of the panel.
///
/// This method will be called when the panel is active or inactive.
///
/// The last_active_panel and current_active_panel will be touched when the panel is active.
fn set_active(&self, _active: bool, _cx: &mut App) {}
/// Set zoomed state of the panel.
///
/// This method will be called when the panel is zoomed or unzoomed.
///
/// Only current Panel will touch this method.
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
/// The addition popup menu of the panel, default is `None`.
fn popup_menu(&self, this: PopupMenu, _cx: &App) -> PopupMenu {
this
}
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
pub trait PanelView: 'static + Send + Sync {
fn panel_id(&self, cx: &App) -> SharedString;
fn title(&self, cx: &App) -> AnyElement;
fn closable(&self, cx: &App) -> bool;
fn zoomable(&self, cx: &App) -> bool;
fn visible(&self, cx: &App) -> bool;
fn set_active(&self, active: bool, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu;
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button>;
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
}
impl<T: Panel> PanelView for Entity<T> {
fn panel_id(&self, cx: &App) -> SharedString {
self.read(cx).panel_id()
}
fn title(&self, cx: &App) -> AnyElement {
self.read(cx).title(cx)
}
fn closable(&self, cx: &App) -> bool {
self.read(cx).closable(cx)
}
fn zoomable(&self, cx: &App) -> bool {
self.read(cx).zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.read(cx).visible(cx)
}
fn set_active(&self, active: bool, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_active(active, cx);
})
}
fn set_zoomed(&self, zoomed: bool, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_zoomed(zoomed, cx);
})
}
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
self.read(cx).popup_menu(menu, cx)
}
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button> {
self.read(cx).toolbar_buttons(window, cx)
}
fn view(&self) -> AnyView {
self.clone().into()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.read(cx).focus_handle(cx)
}
}
impl From<&dyn PanelView> for AnyView {
fn from(handle: &dyn PanelView) -> Self {
handle.view()
}
}
impl<T: Panel> From<&dyn PanelView> for Entity<T> {
fn from(value: &dyn PanelView) -> Self {
value.view().downcast::<T>().unwrap()
}
}
impl PartialEq for dyn PanelView {
fn eq(&self, other: &Self) -> bool {
self.view() == other.view()
}
}

View File

@@ -1,394 +0,0 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{ActiveTheme, AxisExt as _, CLIENT_SIDE_DECORATION_ROUNDING, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
use crate::dock::tab_panel::TabPanel;
use crate::h_flex;
use crate::resizable::{
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizablePanelState, ResizableState,
resizable_panel,
};
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
pub(super) axis: Axis,
focus_handle: FocusHandle,
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
state: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
}
impl Panel for StackPanel {
fn panel_id(&self) -> SharedString {
"StackPanel".into()
}
fn title(&self, _cx: &App) -> gpui::AnyElement {
"StackPanel".into_any_element()
}
}
impl StackPanel {
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| ResizableState::default());
// Bubble up the resize event.
let subscriptions =
vec![
cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| {
cx.emit(PanelEvent::LayoutChanged)
}),
];
Self {
axis,
parent: None,
focus_handle: cx.focus_handle(),
panels: SmallVec::new(),
state,
_subscriptions: subscriptions,
}
}
/// The first level of the stack panel is root, will not have a parent.
fn is_root(&self) -> bool {
self.parent.is_none()
}
/// Return true if self or parent only have last panel.
pub fn is_last_panel(&self, cx: &App) -> bool {
if self.panels.len() > 1 {
return false;
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
}
true
}
pub fn panels_len(&self) -> usize {
self.panels.len()
}
/// Return the index of the panel.
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel)
}
/// Add a panel at the end of the stack.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx);
}
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel_at(
panel,
self.panels_len(),
placement,
size,
dock_area,
window,
cx,
);
}
#[allow(clippy::too_many_arguments)]
pub fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match placement {
Placement::Top | Placement::Left => {
self.insert_panel_before(panel, ix, size, dock_area, window, cx)
}
Placement::Right | Placement::Bottom => {
self.insert_panel_after(panel, ix, size, dock_area, window, cx)
}
}
}
/// Insert a panel at the index.
pub fn insert_panel_before(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix, size, dock_area, window, cx);
}
/// Insert a panel after the index.
pub fn insert_panel_after(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
}
fn insert_panel(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the panel is already in the stack, return.
if self.index_of_panel(panel.clone()).is_some() {
return;
}
let view = cx.entity().clone();
window.defer(cx, {
let panel = panel.clone();
move |window, cx| {
// If the panel is a TabPanel, set its parent to this.
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
stack_panel.update(cx, |stack_panel, _| {
stack_panel.parent = Some(view.downgrade())
});
}
// Subscribe to the panel's layout change event.
_ = dock_area.update(cx, |this, cx| {
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
this.subscribe_panel(&tab_panel, window, cx);
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
this.subscribe_panel(&stack_panel, window, cx);
}
});
}
});
let ix = if ix > self.panels.len() {
self.panels.len()
} else {
ix
};
// Get avg size of all panels to insert new panel, if size is None.
let size = match size {
Some(size) => size,
None => {
let state = self.state.read(cx);
(state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE)
}
};
// Insert panel
self.panels.insert(ix, panel.clone());
// Update resizable state
self.state.update(cx, |state, cx| {
state.insert_panel(Some(size), Some(ix), cx);
});
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove panel from the stack.
///
/// If `ix` is not found, do nothing.
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.index_of_panel(panel.clone()) else {
return;
};
self.panels.remove(ix);
self.state.update(cx, |state, cx| {
state.remove_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx);
}
/// Replace the old panel with the new panel at same index.
pub fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
self.state.update(cx, |state, cx| {
state.replace_panel(ix, ResizablePanelState::default(), cx);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// If children is empty, remove self from parent view.
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_root() {
return;
}
if !self.panels.is_empty() {
return;
}
let view = cx.entity().clone();
if let Some(parent) = self.parent.as_ref() {
_ = parent.update(cx, |parent, cx| {
parent.remove_panel(Arc::new(view.clone()), window, cx);
});
}
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Find the first top left in the stack.
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
{
return Some(panel);
}
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).left_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Find the first top right in the stack.
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
{
return Some(panel);
}
let panel = if self.axis.is_vertical() {
self.panels.first()
} else {
self.panels.last()
};
if let Some(view) = panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).right_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Remove all panels from the stack.
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.panels.clear();
self.state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
}
/// Change the axis of the stack panel.
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
self.axis = axis;
cx.notify();
}
}
impl Focusable for StackPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().panel_background)
.when(cx.theme().platform.is_linux(), |this| {
this.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window};
/// A trait to extend [`gpui::Element`] with additional functionality.
pub trait ElementExt: ParentElement + Sized {
/// Add a prepaint callback to the element.
///
/// This is a helper method to get the bounds of the element after paint.
///
/// The first argument is the bounds of the element in pixels.
///
/// See also [`gpui::canvas`].
fn on_prepaint<F>(self, f: F) -> Self
where
F: FnOnce(Bounds<Pixels>, &mut Window, &mut App) + 'static,
{
self.child(
canvas(
move |bounds, window, cx| f(bounds, window, cx),
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
}
}
impl<T: ParentElement> ElementExt for T {}

View File

@@ -1,21 +0,0 @@
use gpui::{App, ClickEvent, InteractiveElement, Stateful, Window};
pub trait InteractiveElementExt: InteractiveElement {
/// Set the listener for a double click event.
fn on_double_click(
mut self,
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self
where
Self: Sized,
{
self.interactivity().on_click(move |event, window, cx| {
if event.click_count() == 2 {
listener(event, window, cx);
}
});
self
}
}
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}

View File

@@ -1,39 +0,0 @@
use gpui::{Context, FocusHandle, Window};
/// A trait for views that can cycle focus between its children.
///
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
///
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
/// should be cycled, and the cycle will follow the order of the list.
pub trait FocusableCycle {
/// Returns a list of focus handles that should be cycled.
fn cycle_focus_handles(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<FocusHandle>
where
Self: Sized;
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
fn cycle_focus(&self, is_next: bool, window: &mut Window, cx: &mut Context<Self>)
where
Self: Sized,
{
let focused_handle = window.focused(cx);
let handles = self.cycle_focus_handles(window, cx);
let handles = if is_next {
handles
} else {
handles.into_iter().rev().collect()
};
let fallback_handle = handles[0].clone();
let target_focus_handle = handles
.into_iter()
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
.nth(1)
.unwrap_or(fallback_handle);
target_focus_handle.focus(window, cx);
cx.stop_propagation();
}
}

View File

@@ -1,177 +0,0 @@
use std::str::FromStr;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement,
RenderOnce, StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{v_flex, StyledExt as _};
/// The variant of the GroupBox.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum GroupBoxVariant {
#[default]
Normal,
Fill,
}
/// Trait to add GroupBox variant methods to elements.
pub trait GroupBoxVariants: Sized {
/// Set the variant of the [`GroupBox`].
#[must_use]
fn with_variant(self, variant: GroupBoxVariant) -> Self;
/// Set to use [`GroupBoxVariant::Normal`] to GroupBox.
#[must_use]
fn normal(self) -> Self {
self.with_variant(GroupBoxVariant::Normal)
}
/// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
#[must_use]
fn fill(self) -> Self {
self.with_variant(GroupBoxVariant::Fill)
}
}
impl GroupBoxVariant {
/// Convert the GroupBoxVariant to a string.
pub const fn as_str(&self) -> &str {
match self {
GroupBoxVariant::Normal => "normal",
GroupBoxVariant::Fill => "fill",
}
}
}
impl FromStr for GroupBoxVariant {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"fill" => Ok(GroupBoxVariant::Fill),
_ => Ok(GroupBoxVariant::Normal),
}
}
}
/// GroupBox is a styled container element that with
/// an optional title to groups related content together.
#[derive(IntoElement)]
pub struct GroupBox {
id: Option<ElementId>,
variant: GroupBoxVariant,
style: StyleRefinement,
title_style: StyleRefinement,
title: Option<AnyElement>,
content_style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
}
impl GroupBox {
/// Create a new GroupBox.
pub fn new() -> Self {
Self {
id: None,
variant: GroupBoxVariant::default(),
style: StyleRefinement::default(),
title_style: StyleRefinement::default(),
content_style: StyleRefinement::default(),
title: None,
children: SmallVec::new(),
}
}
/// Set the id of the group box, default is None.
#[must_use]
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = Some(id.into());
self
}
/// Set the title of the group box, default is None.
#[must_use]
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
/// Set the style of the title of the group box to override the default style, default is None.
#[must_use]
pub fn title_style(mut self, style: StyleRefinement) -> Self {
self.title_style = style;
self
}
/// Set the style of the content of the group box to override the default style, default is None.
#[must_use]
pub fn content_style(mut self, style: StyleRefinement) -> Self {
self.content_style = style;
self
}
}
impl Default for GroupBox {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for GroupBox {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for GroupBox {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl GroupBoxVariants for GroupBox {
fn with_variant(mut self, variant: GroupBoxVariant) -> Self {
self.variant = variant;
self
}
}
impl RenderOnce for GroupBox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (bg, has_paddings) = match self.variant {
GroupBoxVariant::Normal => (None, false),
GroupBoxVariant::Fill => (Some(cx.theme().surface_background), true),
};
v_flex()
.id(self.id.unwrap_or("group-box".into()))
.w_full()
.when(has_paddings, |this| this.gap_3())
.when(!has_paddings, |this| this.gap_4())
.refine_style(&self.style)
.when_some(self.title, |this, title| {
this.child(
div()
.text_color(cx.theme().text_muted)
.line_height(relative(1.))
.refine_style(&self.title_style)
.text_sm()
.font_semibold()
.child(title),
)
})
.child(
v_flex()
.when_some(bg, |this, bg| this.bg(bg))
.text_color(cx.theme().text)
.when(has_paddings, |this| this.p_2())
.gap_4()
.rounded(cx.theme().radius_lg)
.refine_style(&self.content_style)
.children(self.children),
)
}
}

View File

@@ -1,167 +0,0 @@
use std::fmt::Debug;
use std::time::{Duration, Instant};
pub trait HistoryItem: Clone + PartialEq {
fn version(&self) -> usize;
fn set_version(&mut self, version: usize);
}
/// The History is used to keep track of changes to a model and to allow undo and redo operations.
///
/// This is now used in Input for undo/redo operations. You can also use this in
/// your own models to keep track of changes, for example to track the tab
/// history for prev/next features.
///
/// ## Use cases
///
/// - Undo/redo operations in Input
/// - Tracking tab history for prev/next features
#[derive(Debug)]
pub struct History<I: HistoryItem> {
undos: Vec<I>,
redos: Vec<I>,
last_changed_at: Instant,
version: usize,
max_undo: usize,
group_interval: Option<Duration>,
unique: bool,
pub ignore: bool,
}
impl<I> History<I>
where
I: HistoryItem,
{
pub fn new() -> Self {
Self {
undos: Default::default(),
redos: Default::default(),
ignore: false,
last_changed_at: Instant::now(),
version: 0,
max_undo: 1000,
group_interval: None,
unique: false,
}
}
/// Set the maximum number of undo steps to keep, defaults to 1000.
pub fn max_undo(mut self, max_undo: usize) -> Self {
self.max_undo = max_undo;
self
}
/// Set the history to be unique, defaults to false.
/// If set to true, the history will only keep unique changes.
pub fn unique(mut self) -> Self {
self.unique = true;
self
}
/// Set the interval in milliseconds to group changes, defaults to None.
pub fn group_interval(mut self, group_interval: Duration) -> Self {
self.group_interval = Some(group_interval);
self
}
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
fn inc_version(&mut self) -> usize {
let t = Instant::now();
if Some(self.last_changed_at.elapsed()) > self.group_interval {
self.version += 1;
}
self.last_changed_at = t;
self.version
}
/// Get the current version number.
pub fn version(&self) -> usize {
self.version
}
pub fn push(&mut self, item: I) {
let version = self.inc_version();
if self.undos.len() >= self.max_undo {
self.undos.remove(0);
}
if self.unique {
self.undos.retain(|c| *c != item);
self.redos.retain(|c| *c != item);
}
let mut item = item;
item.set_version(version);
self.undos.push(item);
}
/// Get the undo stack.
pub fn undos(&self) -> &Vec<I> {
&self.undos
}
/// Get the redo stack.
pub fn redos(&self) -> &Vec<I> {
&self.redos
}
/// Clear the undo and redo stacks.
pub fn clear(&mut self) {
self.undos.clear();
self.redos.clear();
}
pub fn undo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.undos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.undos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.undos.pop().unwrap();
changes.push(change);
}
self.redos.extend(changes.clone());
Some(changes)
} else {
None
}
}
pub fn redo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.redos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.redos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.redos.pop().unwrap();
changes.push(change);
}
self.undos.extend(changes.clone());
Some(changes)
} else {
None
}
}
}
impl<I> Default for History<I>
where
I: HistoryItem,
{
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,317 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext, Context, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
SharedString, StyleRefinement, Styled, Svg, Transformation, Window, svg,
};
use theme::ActiveTheme;
use crate::{Sizable, Size};
pub trait IconNamed {
/// Returns the embedded path of the icon.
fn path(self) -> SharedString;
}
impl<T: IconNamed> From<T> for Icon {
fn from(value: T) -> Self {
Icon::build(value)
}
}
#[derive(IntoElement, Clone)]
pub enum IconName {
ArrowLeft,
ArrowRight,
Boom,
Book,
ChevronDown,
CaretDown,
CaretRight,
CaretUp,
Check,
CheckCircle,
Close,
CloseCircle,
CloseCircleFill,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Input,
Info,
Invite,
Inbox,
InboxFill,
Link,
Loader,
Moon,
Plus,
PlusCircle,
Profile,
Reset,
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
UserKey,
Upload,
Usb,
PanelLeft,
PanelLeftOpen,
PanelRight,
PanelRightOpen,
PanelBottom,
PanelBottomOpen,
PaperPlaneFill,
Warning,
WindowClose,
WindowMaximize,
WindowMinimize,
WindowRestore,
Fistbump,
FistbumpFill,
Zoom,
}
impl IconName {
/// Return the icon as a Entity<Icon>
pub fn view(self, cx: &mut App) -> Entity<Icon> {
Icon::build(self).view(cx)
}
}
impl IconNamed for IconName {
fn path(self) -> SharedString {
match self {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
Self::Check => "icons/check.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::PanelBottom => "icons/panel-bottom.svg",
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Warning => "icons/warning.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
Self::Fistbump => "icons/fistbump.svg",
Self::FistbumpFill => "icons/fistbump-fill.svg",
Self::Zoom => "icons/zoom.svg",
}
.into()
}
}
impl From<IconName> for AnyElement {
fn from(val: IconName) -> Self {
Icon::build(val).into_any_element()
}
}
impl RenderOnce for IconName {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
Icon::build(self)
}
}
#[derive(IntoElement)]
pub struct Icon {
base: Svg,
style: StyleRefinement,
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
rotation: Option<Radians>,
}
impl Default for Icon {
fn default() -> Self {
Self {
base: svg().flex_none().size_4(),
style: StyleRefinement::default(),
path: "".into(),
text_color: None,
size: None,
rotation: None,
}
}
}
impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
this.style = self.style.clone();
this.rotation = self.rotation;
this.size = self.size;
this.text_color = self.text_color;
this
}
}
impl Icon {
pub fn new(icon: impl Into<Icon>) -> Self {
icon.into()
}
fn build(name: impl IconNamed) -> Self {
Self::default().path(name.path())
}
/// Set the icon path of the Assets bundle
///
/// For example: `icons/foo.svg`
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
self.path = path.into();
self
}
/// Create a new view for the icon
pub fn view(self, cx: &mut App) -> Entity<Icon> {
cx.new(|_| self)
}
pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
self.base = self.base.with_transformation(transformation);
self
}
pub fn empty() -> Self {
Self::default()
}
/// Rotate the icon by the given angle
pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
self.base = self
.base
.with_transformation(Transformation::rotate(radians));
self
}
}
impl Styled for Icon {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
self.text_color = Some(color.into());
self
}
}
impl Sizable for Icon {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = Some(size.into());
self
}
}
impl RenderOnce for Icon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| window.text_style().color);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
let mut base = self.base;
*base.style() = self.style;
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_4(),
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.path(self.path)
}
}
impl From<Icon> for AnyElement {
fn from(val: Icon) -> Self {
val.into_any_element()
}
}
impl Render for Icon {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
let mut base = svg().flex_none();
*base.style() = self.style.clone();
base.flex_shrink_0()
.text_color(text_color)
.when(!has_base_size, |this| this.size(text_size))
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_4(),
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.path(self.path.clone())
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})
}
}

View File

@@ -1,69 +0,0 @@
use std::fmt::{Debug, Display};
use gpui::ElementId;
/// Represents an index path in a list, which consists of a section index,
///
/// The default values for section, row, and column are all set to 0.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IndexPath {
/// The section index.
pub section: usize,
/// The item index in the section.
pub row: usize,
/// The column index.
pub column: usize,
}
impl From<IndexPath> for ElementId {
fn from(path: IndexPath) -> Self {
ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into())
}
}
impl Display for IndexPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"IndexPath(section: {}, row: {}, column: {})",
self.section, self.row, self.column
)
}
}
impl IndexPath {
/// Create a new index path with the specified section and row.
///
/// The `section` is set to 0 by default.
/// The `column` is set to 0 by default.
pub fn new(row: usize) -> Self {
IndexPath {
section: 0,
row,
..Default::default()
}
}
/// Set the section for the index path.
pub fn section(mut self, section: usize) -> Self {
self.section = section;
self
}
/// Set the row for the index path.
pub fn row(mut self, row: usize) -> Self {
self.row = row;
self
}
/// Set the column for the index path.
pub fn column(mut self, column: usize) -> Self {
self.column = column;
self
}
/// Check if the self is equal to the given index path (Same section and row).
pub fn eq_row(&self, index: IndexPath) -> bool {
self.section == index.section && self.row == index.row
}
}

View File

@@ -1,66 +0,0 @@
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, ease_in_out, percentage, Animation, AnimationExt as _, App, Hsla, IntoElement,
ParentElement, RenderOnce, Styled as _, Transformation, Window,
};
use crate::{Icon, IconName, Sizable, Size};
#[derive(IntoElement)]
pub struct Indicator {
size: Size,
icon: Icon,
speed: Duration,
color: Option<Hsla>,
}
impl Indicator {
pub fn new() -> Self {
Self {
size: Size::Small,
speed: Duration::from_secs(1),
icon: Icon::new(IconName::Loader),
color: None,
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = icon.into();
self
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Sizable for Indicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Indicator {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
div().child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.with_animation(
"circle",
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
)
}
}

View File

@@ -1,97 +0,0 @@
use std::time::Duration;
use gpui::{px, Context, Pixels};
static INTERVAL: Duration = Duration::from_millis(500);
static PAUSE_DELAY: Duration = Duration::from_millis(300);
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
/// To manage the Input cursor blinking.
///
/// It will start blinking with a interval of 500ms.
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
///
/// The input painter will check if this in visible state, then it will draw the cursor.
pub struct BlinkCursor {
visible: bool,
paused: bool,
epoch: usize,
}
impl BlinkCursor {
pub fn new() -> Self {
Self {
visible: false,
paused: false,
epoch: 0,
}
}
/// Start the blinking
pub fn start(&mut self, cx: &mut Context<Self>) {
self.blink(self.epoch, cx);
}
pub fn stop(&mut self, cx: &mut Context<Self>) {
self.epoch = 0;
cx.notify();
}
fn next_epoch(&mut self) -> usize {
self.epoch += 1;
self.epoch
}
fn blink(&mut self, epoch: usize, cx: &mut Context<Self>) {
if self.paused || epoch != self.epoch {
self.visible = true;
return;
}
self.visible = !self.visible;
cx.notify();
// Schedule the next blink
let epoch = self.next_epoch();
cx.spawn(async move |this, cx| {
cx.background_executor().timer(INTERVAL).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| this.blink(epoch, cx));
}
})
.detach();
}
pub fn visible(&self) -> bool {
// Keep showing the cursor if paused
self.paused || self.visible
}
/// Pause the blinking, and delay 500ms to resume the blinking.
pub fn pause(&mut self, cx: &mut Context<Self>) {
self.paused = true;
self.visible = true;
cx.notify();
// delay 500ms to start the blinking
let epoch = self.next_epoch();
cx.spawn(async move |this, cx| {
cx.background_executor().timer(PAUSE_DELAY).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| {
this.paused = false;
this.blink(epoch, cx);
});
}
})
.detach();
}
}
impl Default for BlinkCursor {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,40 +0,0 @@
use std::fmt::Debug;
use crate::history::HistoryItem;
use crate::input::cursor::Selection;
#[derive(Debug, PartialEq, Clone)]
pub struct Change {
pub(crate) old_range: Selection,
pub(crate) old_text: String,
pub(crate) new_range: Selection,
pub(crate) new_text: String,
version: usize,
}
impl Change {
pub fn new(
old_range: impl Into<Selection>,
old_text: &str,
new_range: impl Into<Selection>,
new_text: &str,
) -> Self {
Self {
old_range: old_range.into(),
old_text: old_text.to_string(),
new_range: new_range.into(),
new_text: new_text.to_string(),
version: 0,
}
}
}
impl HistoryItem for Change {
fn version(&self) -> usize {
self.version
}
fn set_version(&mut self, version: usize) {
self.version = version;
}
}

View File

@@ -1,15 +0,0 @@
use gpui::{App, Styled};
use theme::ActiveTheme;
use crate::button::{Button, ButtonVariants};
use crate::{Icon, IconName, Sizable};
#[inline]
pub(crate) fn clear_button(cx: &App) -> Button {
Button::new("clean")
.icon(Icon::new(IconName::CloseCircle))
.tooltip("Clear")
.small()
.transparent()
.text_color(cx.theme().text_muted)
}

View File

@@ -1,46 +0,0 @@
use std::ops::Range;
/// A selection in the text, represented by start and end byte indices.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub struct Selection {
pub start: usize,
pub end: usize,
}
impl Selection {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
/// Clears the selection, setting start and end to 0.
pub fn clear(&mut self) {
self.start = 0;
self.end = 0;
}
/// Checks if the given offset is within the selection range.
pub fn contains(&self, offset: usize) -> bool {
offset >= self.start && offset < self.end
}
}
impl From<Range<usize>> for Selection {
fn from(value: Range<usize>) -> Self {
Self::new(value.start, value.end)
}
}
impl From<Selection> for Range<usize> {
fn from(value: Selection) -> Self {
value.start..value.end
}
}
pub type Position = lsp_types::Position;

View File

@@ -1,888 +0,0 @@
use std::ops::Range;
use std::rc::Rc;
use gpui::{
App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, Hitbox,
IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, Point, ShapedLine,
SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, fill, point, px,
relative, size,
};
use rope::Rope;
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::blink_cursor::CURSOR_WIDTH;
use super::rope_ext::RopeExt;
use super::state::{InputState, LastLayout};
use crate::Root;
const BOTTOM_MARGIN_ROWS: usize = 3;
pub(super) const RIGHT_MARGIN: Pixels = px(10.);
pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
pub(super) struct TextElement {
pub(crate) state: Entity<InputState>,
placeholder: SharedString,
}
impl TextElement {
pub(super) fn new(state: Entity<InputState>) -> Self {
Self {
state,
placeholder: SharedString::default(),
}
}
/// Set the placeholder text of the input field.
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) {
window.on_mouse_event({
let state = self.state.clone();
move |event: &MouseMoveEvent, _, window, cx| {
if event.pressed_button == Some(MouseButton::Left) {
state.update(cx, |state, cx| {
state.on_drag_move(event, window, cx);
});
}
}
});
}
/// Returns the:
///
/// - cursor bounds
/// - scroll offset
/// - current row index (No only the visible lines, but all lines)
///
/// This method also will update for track scroll to cursor.
fn layout_cursor(
&self,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
_: &mut Window,
cx: &mut App,
) -> (Option<Bounds<Pixels>>, Point<Pixels>, Option<usize>) {
let state = self.state.read(cx);
let line_height = last_layout.line_height;
let visible_range = &last_layout.visible_range;
let lines = &last_layout.lines;
let text_wrapper = &state.text_wrapper;
let line_number_width = last_layout.line_number_width;
let mut selected_range = state.selected_range;
if let Some(ime_marked_range) = &state.ime_marked_range {
selected_range = (ime_marked_range.end..ime_marked_range.end).into();
}
let cursor = state.cursor();
let mut current_row = None;
let mut scroll_offset = state.scroll_handle.offset();
let mut cursor_bounds = None;
// If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input.
let top_bottom_margin = if state.mode.is_auto_grow() {
#[allow(clippy::if_same_then_else)]
line_height
} else if visible_range.len() < BOTTOM_MARGIN_ROWS * 8 {
line_height
} else {
BOTTOM_MARGIN_ROWS * line_height
};
// The cursor corresponds to the current cursor position in the text no only the line.
let mut cursor_pos = None;
let mut cursor_start = None;
let mut cursor_end = None;
let mut prev_lines_offset = 0;
let mut offset_y = px(0.);
for (ix, wrap_line) in text_wrapper.lines.iter().enumerate() {
let row = ix;
let line_origin = point(px(0.), offset_y);
// break loop if all cursor positions are found
if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() {
break;
}
let in_visible_range = ix >= visible_range.start;
if let Some(line) = in_visible_range
.then(|| lines.get(ix.saturating_sub(visible_range.start)))
.flatten()
{
// If in visible range lines
if cursor_pos.is_none() {
let offset = cursor.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
current_row = Some(row);
cursor_pos = Some(line_origin + pos);
}
}
if cursor_start.is_none() {
let offset = selected_range.start.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
cursor_start = Some(line_origin + pos);
}
}
if cursor_end.is_none() {
let offset = selected_range.end.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
cursor_end = Some(line_origin + pos);
}
}
offset_y += line.size(line_height).height;
// +1 for the last `\n`
prev_lines_offset += line.len() + 1;
} else {
// If not in the visible range.
// Just increase the offset_y and prev_lines_offset.
// This will let the scroll_offset to track the cursor position correctly.
if prev_lines_offset >= cursor && cursor_pos.is_none() {
current_row = Some(row);
cursor_pos = Some(line_origin);
}
if prev_lines_offset >= selected_range.start && cursor_start.is_none() {
cursor_start = Some(line_origin);
}
if prev_lines_offset >= selected_range.end && cursor_end.is_none() {
cursor_end = Some(line_origin);
}
offset_y += wrap_line.height(line_height);
// +1 for the last `\n`
prev_lines_offset += wrap_line.len() + 1;
}
}
if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) =
(cursor_pos, cursor_start, cursor_end)
{
let selection_changed = state.last_selected_range != Some(selected_range);
if selection_changed {
scroll_offset.x = if scroll_offset.x + cursor_pos.x
> (bounds.size.width - line_number_width - RIGHT_MARGIN)
{
// cursor is out of right
bounds.size.width - line_number_width - RIGHT_MARGIN - cursor_pos.x
} else if scroll_offset.x + cursor_pos.x < px(0.) {
// cursor is out of left
scroll_offset.x - cursor_pos.x
} else {
scroll_offset.x
};
// If we change the scroll_offset.y, GPUI will render and trigger the next run loop.
// So, here we just adjust offset by `line_height` for move smooth.
scroll_offset.y =
if scroll_offset.y + cursor_pos.y > bounds.size.height - top_bottom_margin {
// cursor is out of bottom
scroll_offset.y - line_height
} else if scroll_offset.y + cursor_pos.y < top_bottom_margin {
// cursor is out of top
(scroll_offset.y + line_height).min(px(0.))
} else {
scroll_offset.y
};
if state.selection_reversed {
if scroll_offset.x + cursor_start.x < px(0.) {
// selection start is out of left
scroll_offset.x = -cursor_start.x;
}
if scroll_offset.y + cursor_start.y < px(0.) {
// selection start is out of top
scroll_offset.y = -cursor_start.y;
}
} else {
if scroll_offset.x + cursor_end.x <= px(0.) {
// selection end is out of left
scroll_offset.x = -cursor_end.x;
}
if scroll_offset.y + cursor_end.y <= px(0.) {
// selection end is out of top
scroll_offset.y = -cursor_end.y;
}
}
}
// cursor bounds
let cursor_height = line_height;
cursor_bounds = Some(Bounds::new(
point(
bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x,
bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.),
),
size(CURSOR_WIDTH, cursor_height),
));
}
if let Some(deferred_scroll_offset) = state.deferred_scroll_offset {
scroll_offset = deferred_scroll_offset;
}
bounds.origin += scroll_offset;
(cursor_bounds, scroll_offset, current_row)
}
/// Layout the match range to a Path.
pub(crate) fn layout_match_range(
range: Range<usize>,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
) -> Option<Path<Pixels>> {
if range.is_empty() {
return None;
}
if range.start < last_layout.visible_range_offset.start
|| range.end > last_layout.visible_range_offset.end
{
return None;
}
let line_height = last_layout.line_height;
let visible_top = last_layout.visible_top;
let visible_start_offset = last_layout.visible_range_offset.start;
let lines = &last_layout.lines;
let line_number_width = last_layout.line_number_width;
let start_ix = range.start;
let end_ix = range.end;
let mut prev_lines_offset = visible_start_offset;
let mut offset_y = visible_top;
let mut line_corners = vec![];
for line in lines.iter() {
let line_size = line.size(line_height);
let line_wrap_width = line_size.width;
let line_origin = point(px(0.), offset_y);
let line_cursor_start =
line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height);
let line_cursor_end =
line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height);
if line_cursor_start.is_some() || line_cursor_end.is_some() {
let start = line_cursor_start
.unwrap_or_else(|| line.position_for_index(0, line_height).unwrap());
let end = line_cursor_end
.unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap());
// Split the selection into multiple items
let wrapped_lines =
(end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize;
let mut end_x = end.x;
if wrapped_lines > 0 {
end_x = line_wrap_width;
}
// Ensure at least 6px width for the selection for empty lines.
end_x = end_x.max(start.x + px(6.));
line_corners.push(Corners {
top_left: line_origin + point(start.x, start.y),
top_right: line_origin + point(end_x, start.y),
bottom_left: line_origin + point(start.x, start.y + line_height),
bottom_right: line_origin + point(end_x, start.y + line_height),
});
// wrapped lines
for i in 1..=wrapped_lines {
let start = point(px(0.), start.y + i as f32 * line_height);
let mut end = point(end.x, end.y + i as f32 * line_height);
if i < wrapped_lines {
end.x = line_size.width;
}
line_corners.push(Corners {
top_left: line_origin + point(start.x, start.y),
top_right: line_origin + point(end.x, start.y),
bottom_left: line_origin + point(start.x, start.y + line_height),
bottom_right: line_origin + point(end.x, start.y + line_height),
});
}
}
if line_cursor_start.is_some() && line_cursor_end.is_some() {
break;
}
offset_y += line_size.height;
// +1 for skip the last `\n`
prev_lines_offset += line.len() + 1;
}
let mut points = vec![];
if line_corners.is_empty() {
return None;
}
// Fix corners to make sure the left to right direction
for corners in &mut line_corners {
if corners.top_left.x > corners.top_right.x {
std::mem::swap(&mut corners.top_left, &mut corners.top_right);
std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right);
}
}
for corners in &line_corners {
points.push(corners.top_right);
points.push(corners.bottom_right);
points.push(corners.bottom_left);
}
let mut rev_line_corners = line_corners.iter().rev().peekable();
while let Some(corners) = rev_line_corners.next() {
points.push(corners.top_left);
if let Some(next) = rev_line_corners.peek()
&& next.top_left.x > corners.top_left.x
{
points.push(point(next.top_left.x, corners.top_left.y));
}
}
// print_points_as_svg_path(&line_corners, &points);
let path_origin = bounds.origin + point(line_number_width, px(0.));
let first_p = *points.first().unwrap();
let mut builder = gpui::PathBuilder::fill();
builder.move_to(path_origin + first_p);
for p in points.iter().skip(1) {
builder.line_to(path_origin + *p);
}
builder.build().ok()
}
fn layout_selections(
&self,
last_layout: &LastLayout,
bounds: &mut Bounds<Pixels>,
cx: &mut App,
) -> Option<Path<Pixels>> {
let state = self.state.read(cx);
let mut selected_range = state.selected_range;
if let Some(ime_marked_range) = &state.ime_marked_range
&& !ime_marked_range.is_empty()
{
selected_range = (ime_marked_range.end..ime_marked_range.end).into();
}
if selected_range.is_empty() {
return None;
}
let (start_ix, end_ix) = if selected_range.start < selected_range.end {
(selected_range.start, selected_range.end)
} else {
(selected_range.end, selected_range.start)
};
let range = start_ix.max(last_layout.visible_range_offset.start)
..end_ix.min(last_layout.visible_range_offset.end);
Self::layout_match_range(range, last_layout, bounds)
}
/// Calculate the visible range of lines in the viewport.
///
/// Returns
///
/// - visible_range: The visible range is based on unwrapped lines (Zero based).
/// - visible_top: The top position of the first visible line in the scroll viewport.
fn calculate_visible_range(
&self,
state: &InputState,
line_height: Pixels,
input_height: Pixels,
) -> (Range<usize>, Pixels) {
// Add extra rows to avoid showing empty space when scroll to bottom.
let extra_rows = 1;
let mut visible_top = px(0.);
if state.mode.is_single_line() {
return (0..1, visible_top);
}
let total_lines = state.text_wrapper.len();
let scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset {
deferred_scroll_offset.y
} else {
state.scroll_handle.offset().y
};
let mut visible_range = 0..total_lines;
let mut line_bottom = px(0.);
for (ix, line) in state.text_wrapper.lines.iter().enumerate() {
let wrapped_height = line.height(line_height);
line_bottom += wrapped_height;
if line_bottom < -scroll_top {
visible_top = line_bottom - wrapped_height;
visible_range.start = ix;
}
if line_bottom + scroll_top >= input_height {
visible_range.end = (ix + extra_rows).min(total_lines);
break;
}
}
(visible_range, visible_top)
}
}
pub(super) struct PrepaintState {
/// The lines of entire lines.
last_layout: LastLayout,
/// The lines only contains the visible lines in the viewport, based on `visible_range`.
///
/// The child is the soft lines.
line_numbers: Option<Vec<SmallVec<[ShapedLine; 1]>>>,
/// Size of the scrollable area by entire lines.
scroll_size: Size<Pixels>,
cursor_bounds: Option<Bounds<Pixels>>,
cursor_scroll_offset: Point<Pixels>,
selection_path: Option<Path<Pixels>>,
hover_highlight_path: Option<Path<Pixels>>,
search_match_paths: Vec<(Path<Pixels>, bool)>,
hover_definition_hitbox: Option<Hitbox>,
bounds: Bounds<Pixels>,
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TextElement {
type PrepaintState = PrepaintState;
type RequestLayoutState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let state = self.state.read(cx);
let line_height = window.line_height();
let mut style = Style::default();
style.size.width = relative(1.).into();
if state.mode.is_multi_line() {
style.flex_grow = 1.0;
style.size.height = relative(1.).into();
if state.mode.is_auto_grow() {
// Auto grow to let height match to rows, but not exceed max rows.
let rows = state.mode.max_rows().min(state.mode.rows());
style.min_size.height = (rows * line_height).into();
} else {
style.min_size.height = line_height.into();
}
} else {
// For single-line inputs, the minimum height should be the line height
style.size.height = line_height.into();
};
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let state = self.state.read(cx);
let line_height = window.line_height();
let (visible_range, visible_top) =
self.calculate_visible_range(state, line_height, bounds.size.height);
let visible_start_offset = state.text.line_start_offset(visible_range.start);
let visible_end_offset = state
.text
.line_end_offset(visible_range.end.saturating_sub(1));
let state = self.state.read(cx);
let multi_line = state.mode.is_multi_line();
let text = state.text.clone();
let is_empty = text.is_empty();
let placeholder = self.placeholder.clone();
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let mut bounds = bounds;
let (display_text, text_color) = if is_empty {
(Rope::from(placeholder.as_str()), cx.theme().text_muted)
} else if state.masked {
(
Rope::from("*".repeat(text.chars_count()).as_str()),
cx.theme().text,
)
} else {
(text.clone(), cx.theme().text)
};
let line_number_width = px(0.);
let run = TextRun {
len: display_text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
};
let marked_run = TextRun {
len: 0,
font: style.font(),
color: text_color,
background_color: None,
underline: Some(UnderlineStyle {
thickness: px(1.),
color: Some(text_color),
wavy: false,
}),
strikethrough: None,
};
let runs = if !is_empty {
vec![run]
} else if let Some(ime_marked_range) = &state.ime_marked_range {
// IME marked text
vec![
TextRun {
len: ime_marked_range.start,
..run.clone()
},
TextRun {
len: ime_marked_range.end - ime_marked_range.start,
underline: marked_run.underline,
..run.clone()
},
TextRun {
len: display_text.len() - ime_marked_range.end,
..run.clone()
},
]
.into_iter()
.filter(|run| run.len > 0)
.collect()
} else {
vec![run]
};
let wrap_width = if multi_line && state.soft_wrap {
Some(bounds.size.width - line_number_width - RIGHT_MARGIN)
} else {
None
};
// NOTE: Here 50 lines about 150µs
// let measure = crate::Measure::new("shape_text");
let visible_text = display_text
.slice_rows(visible_range.start as u32..visible_range.end as u32)
.to_string();
let lines = window
.text_system()
.shape_text(visible_text.into(), font_size, &runs, wrap_width, None)
.expect("failed to shape text");
// measure.end();
let mut longest_line_width = wrap_width.unwrap_or(px(0.));
if state.mode.is_multi_line() && !state.soft_wrap && lines.len() > 1 {
let longtest_line: SharedString = state
.text
.line(state.text.summary().longest_row as usize)
.to_string()
.into();
longest_line_width = window
.text_system()
.shape_line(
longtest_line.clone(),
font_size,
&[TextRun {
len: longtest_line.len(),
font: style.font(),
color: gpui::black(),
background_color: None,
underline: None,
strikethrough: None,
}],
wrap_width,
)
.width;
}
let total_wrapped_lines = state.text_wrapper.len();
let empty_bottom_height = px(0.);
let scroll_size = size(
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
longest_line_width + line_number_width + RIGHT_MARGIN
} else {
longest_line_width
},
(total_wrapped_lines as f32 * line_height + empty_bottom_height)
.max(bounds.size.height),
);
let mut last_layout = LastLayout {
visible_range,
visible_top,
visible_range_offset: visible_start_offset..visible_end_offset,
line_height,
wrap_width,
line_number_width,
lines: Rc::new(lines),
cursor_bounds: None,
};
// `position_for_index` for example
//
// #### text
//
// Hello 世界this is GPUI component.
// The GPUI Component is a collection of UI components for
// GPUI framework, including Button, Input, Checkbox, Radio,
// Dropdown, Tab, and more...
//
// wrap_width: 444px, line_height: 20px
//
// #### lines[0]
//
// | index | pos | line |
// |-------|------------------|------|
// | 5 | (37 px, 0.0) | 0 |
// | 38 | (261.7 px, 20.0) | 0 |
// | 40 | None | - |
//
// #### lines[1]
//
// | index | position | line |
// |-------|-----------------------|------|
// | 5 | (43.578125 px, 0.0) | 0 |
// | 56 | (422.21094 px, 0.0) | 0 |
// | 57 | (11.6328125 px, 20.0) | 1 |
// | 114 | (429.85938 px, 20.0) | 1 |
// | 115 | (11.3125 px, 40.0) | 2 |
// Calculate the scroll offset to keep the cursor in view
let (cursor_bounds, cursor_scroll_offset, _) =
self.layout_cursor(&last_layout, &mut bounds, window, cx);
last_layout.cursor_bounds = cursor_bounds;
let selection_path = self.layout_selections(&last_layout, &mut bounds, cx);
let search_match_paths = vec![];
let hover_highlight_path = None;
let line_numbers = None;
let hover_definition_hitbox = None;
PrepaintState {
bounds,
last_layout,
scroll_size,
line_numbers,
cursor_bounds,
cursor_scroll_offset,
selection_path,
search_match_paths,
hover_highlight_path,
hover_definition_hitbox,
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
input_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let focus_handle = self.state.read(cx).focus_handle.clone();
let show_cursor = self.state.read(cx).show_cursor(window, cx);
let focused = focus_handle.is_focused(window);
let bounds = prepaint.bounds;
let selected_range = self.state.read(cx).selected_range;
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.state.clone()),
cx,
);
// Set Root focused_input when self is focused
if focused {
let state = self.state.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.state.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 multi line text
let line_height = window.line_height();
let origin = bounds.origin;
let invisible_top_padding = prepaint.last_layout.visible_top;
let mut mask_offset_y = px(0.);
if self.state.read(cx).masked {
// Move down offset for vertical centering the *****
if cfg!(target_os = "macos") {
mask_offset_y = px(3.);
} else {
mask_offset_y = px(2.5);
}
}
// Paint active line
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
// Each item is the normal lines.
for lines in line_numbers.iter() {
let height = line_height * lines.len() as f32;
offset_y += height;
}
}
// Paint selections
if window.is_window_active() {
let secondary_selection = cx.theme().selection;
for (path, is_active) in prepaint.search_match_paths.iter() {
window.paint_path(path.clone(), secondary_selection);
if *is_active {
window.paint_path(path.clone(), cx.theme().selection);
}
}
if let Some(path) = prepaint.selection_path.take() {
window.paint_path(path, cx.theme().selection);
}
// Paint hover highlight
if let Some(path) = prepaint.hover_highlight_path.take() {
window.paint_path(path, secondary_selection);
}
}
// Paint text
let mut offset_y = mask_offset_y + invisible_top_padding;
for line in prepaint.last_layout.lines.iter() {
let p = point(
origin.x + prepaint.last_layout.line_number_width,
origin.y + offset_y,
);
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line.size(line_height).height;
}
// Paint blinking cursor
if focused
&& show_cursor
&& let Some(mut cursor_bounds) = prepaint.cursor_bounds.take()
{
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
window.paint_quad(fill(cursor_bounds, cx.theme().cursor));
}
// Paint line numbers
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
// Paint line number background
window.paint_quad(fill(
Bounds {
origin: input_bounds.origin,
size: size(
prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN,
input_bounds.size.height,
),
},
cx.theme().background,
));
// Each item is the normal lines.
for lines in line_numbers.iter() {
let p = point(input_bounds.origin.x, origin.y + offset_y);
for line in lines {
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line_height;
}
}
}
self.state.update(cx, |state, cx| {
state.last_layout = Some(prepaint.last_layout.clone());
state.last_bounds = Some(bounds);
state.last_cursor = Some(state.cursor());
state.set_input_bounds(input_bounds, cx);
state.last_selected_range = Some(selected_range);
state.scroll_size = prepaint.scroll_size;
state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx);
state.deferred_scroll_offset = None;
cx.notify();
});
if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() {
window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
}
self.paint_mouse_listeners(window, cx);
}
}

View File

@@ -1,409 +0,0 @@
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<char> {
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<MaskToken>,
},
Number {
/// Group separator, e.g. "," or " "
separator: Option<char>,
/// Number of fraction digits, e.g. 2 for 123.45
fraction: Option<usize>,
},
}
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<MaskToken>> {
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<char>) -> Self {
Self::Number {
separator: sep,
fraction: None,
}
}
pub fn placeholder(&self) -> Option<String> {
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<char> = 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;
}
let sign_positions: Vec<usize> = int_part
.chars()
.enumerate()
.filter_map(|(i, ch)| match is_sign(&ch) {
true => Some(i),
false => None,
})
.collect();
// only one sign is valid
// sign is only valid at the beginning of the string
if sign_positions.len() > 1 || sign_positions.first() > Some(&0) {
return false;
}
// check if the integer part is valid
if !int_part.chars().enumerate().all(|(i, ch)| {
ch.is_ascii_digit() || is_sign(&ch) && i == 0 || Some(ch) == *separator
}) {
return false;
}
// check if the fraction part is valid
if let Some(frac) = frac_part
&& !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)
&& 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::<String>()
});
// Reverse the integer part for easier grouping
let mut chars: Vec<char> = int_part.chars().rev().collect();
// Removing the sign from formatting to avoid cases such as: -,123
let maybe_signed = chars.iter().position(is_sign).map(|pos| chars.remove(pos));
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
};
let final_str = if let Some(sign) = maybe_signed {
format!("{sign}{final_str}")
} else {
final_str
};
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<char> = 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<char> = 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(),
}
}
}
#[inline]
fn is_sign(ch: &char) -> bool {
matches!(ch, '+' | '-')
}

View File

@@ -1,15 +0,0 @@
mod blink_cursor;
mod change;
mod cursor;
mod element;
mod mask_pattern;
mod mode;
mod rope_ext;
mod state;
mod text_input;
mod text_wrapper;
pub(crate) mod clear_button;
pub use state::*;
pub use text_input::*;

View File

@@ -1,129 +0,0 @@
use gpui::SharedString;
use super::text_wrapper::TextWrapper;
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
/// Default is 2
pub tab_size: usize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
}
#[derive(Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
tab: TabSize,
rows: usize,
},
AutoGrow {
rows: usize,
min_rows: usize,
max_rows: usize,
},
}
#[allow(unused)]
impl InputMode {
#[inline]
pub(super) fn is_single_line(&self) -> bool {
matches!(self, InputMode::SingleLine)
}
#[inline]
pub(super) fn is_auto_grow(&self) -> bool {
matches!(self, InputMode::AutoGrow { .. })
}
#[inline]
pub(super) fn is_multi_line(&self) -> bool {
matches!(
self,
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. }
)
}
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::MultiLine { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
max_rows,
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
_ => {}
}
}
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) {
if self.is_single_line() {
return;
}
let wrapped_lines = text_wrapper.len();
self.set_rows(wrapped_lines);
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
match self {
InputMode::MultiLine { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
}
.max(1)
}
/// At least 1 row be return.
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
.max(1)
}
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1,
}
}
#[inline]
pub(super) fn tab_size(&self) -> Option<&TabSize> {
match self {
InputMode::MultiLine { tab, .. } => Some(tab),
_ => None,
}
}
}

View File

@@ -1,208 +0,0 @@
use std::ops::Range;
use rope::{Point, Rope};
use super::cursor::Position;
/// An extension trait for `Rope` to provide additional utility methods.
pub trait RopeExt {
/// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`.
///
/// Return empty rope if the row (0-based) is out of bounds.
fn line(&self, row: usize) -> Rope;
/// Start offset of the line at the given row (0-based) index.
fn line_start_offset(&self, row: usize) -> usize;
/// Line the end offset (including `\n`) of the line at the given row (0-based) index.
///
/// Return the end of the rope if the row is out of bounds.
fn line_end_offset(&self, row: usize) -> usize;
/// Return the number of lines in the rope.
fn lines_len(&self) -> usize;
/// Return the lines iterator.
///
/// Each line is including the `\r` at the end, but not `\n`.
fn lines(&self) -> RopeLines;
/// Check is equal to another rope.
fn eq(&self, other: &Rope) -> bool;
/// Total number of characters in the rope.
fn chars_count(&self) -> usize;
/// Get char at the given offset (byte).
///
/// If the offset is in the middle of a multi-byte character will panic.
///
/// If the offset is out of bounds, return None.
fn char_at(&self, offset: usize) -> Option<char>;
/// Get the byte offset from the given line, column [`Position`] (0-based).
fn position_to_offset(&self, line_col: &Position) -> usize;
/// Get the line, column [`Position`] (0-based) from the given byte offset.
fn offset_to_position(&self, offset: usize) -> Position;
/// Get the word byte range at the given offset (byte).
#[allow(dead_code)]
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
/// Get word at the given offset (byte).
#[allow(dead_code)]
fn word_at(&self, offset: usize) -> String;
}
/// An iterator over the lines of a `Rope`.
pub struct RopeLines {
row: usize,
end_row: usize,
rope: Rope,
}
impl RopeLines {
/// Create a new `RopeLines` iterator.
pub fn new(rope: Rope) -> Self {
let end_row = rope.lines_len();
Self {
row: 0,
end_row,
rope,
}
}
}
impl Iterator for RopeLines {
type Item = Rope;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if self.row >= self.end_row {
return None;
}
let line = self.rope.line(self.row);
self.row += 1;
Some(line)
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.row = self.row.saturating_add(n);
self.next()
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.end_row - self.row;
(len, Some(len))
}
}
impl std::iter::ExactSizeIterator for RopeLines {}
impl std::iter::FusedIterator for RopeLines {}
impl RopeExt for Rope {
fn line(&self, row: usize) -> Rope {
let start = self.line_start_offset(row);
let end = start + self.line_len(row as u32) as usize;
self.slice(start..end)
}
fn line_start_offset(&self, row: usize) -> usize {
let row = row as u32;
self.point_to_offset(Point::new(row, 0))
}
fn position_to_offset(&self, pos: &Position) -> usize {
let line = self.line(pos.line as usize);
self.line_start_offset(pos.line as usize)
+ line
.chars()
.take(pos.character as usize)
.map(|c| c.len_utf8())
.sum::<usize>()
}
fn offset_to_position(&self, offset: usize) -> Position {
let point = self.offset_to_point(offset);
let line = self.line(point.row as usize);
let column = line.clip_offset(point.column as usize, sum_tree::Bias::Left);
let character = line.slice(0..column).chars().count();
Position::new(point.row, character as u32)
}
fn line_end_offset(&self, row: usize) -> usize {
if row > self.max_point().row as usize {
return self.len();
}
self.line_start_offset(row) + self.line_len(row as u32) as usize
}
fn lines_len(&self) -> usize {
self.max_point().row as usize + 1
}
fn lines(&self) -> RopeLines {
RopeLines::new(self.clone())
}
fn eq(&self, other: &Rope) -> bool {
self.summary() == other.summary()
}
fn chars_count(&self) -> usize {
self.chars().count()
}
fn char_at(&self, offset: usize) -> Option<char> {
if offset > self.len() {
return None;
}
let offset = self.clip_offset(offset, sum_tree::Bias::Left);
self.slice(offset..self.len()).chars().next()
}
fn word_range(&self, offset: usize) -> Option<Range<usize>> {
if offset >= self.len() {
return None;
}
let offset = self.clip_offset(offset, sum_tree::Bias::Left);
let mut left = String::new();
for c in self.reversed_chars_at(offset) {
if c.is_alphanumeric() || c == '_' {
left.insert(0, c);
} else {
break;
}
}
let start = offset.saturating_sub(left.len());
let right = self
.chars_at(offset)
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>();
let end = offset + right.len();
if start == end {
None
} else {
Some(start..end)
}
}
fn word_at(&self, offset: usize) -> String {
if let Some(range) = self.word_range(offset) {
self.slice(range).to_string()
} else {
String::new()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,314 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
Window,
};
use theme::ActiveTheme;
use super::clear_button::clear_button;
use super::state::{InputState, CONTEXT};
use crate::button::{Button, ButtonVariants};
use crate::indicator::Indicator;
use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt};
#[derive(IntoElement)]
pub struct TextInput {
state: Entity<InputState>,
style: StyleRefinement,
size: Size,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
height: Option<DefiniteLength>,
appearance: bool,
cleanable: bool,
mask_toggle: bool,
disabled: bool,
bordered: bool,
focus_bordered: bool,
}
impl Sizable for TextInput {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl TextInput {
/// Create a new [`TextInput`] element bind to the [`InputState`].
pub fn new(state: &Entity<InputState>) -> Self {
Self {
state: state.clone(),
size: Size::default(),
style: StyleRefinement::default(),
prefix: None,
suffix: None,
height: None,
appearance: true,
cleanable: false,
mask_toggle: false,
disabled: false,
bordered: true,
focus_bordered: true,
}
}
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<DefiniteLength>) -> Self {
self.height = Some(height.into());
self
}
/// Set the appearance of the input field, if false the input field will no border, background.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Set the bordered for the input, default: true
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
/// Set focus border for the input, default is true.
pub fn focus_bordered(mut self, bordered: bool) -> Self {
self.focus_bordered = bordered;
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
}
fn render_toggle_mask_button(state: Entity<InputState>) -> 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 Styled for TextInput {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for TextInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
let font = window.text_style().font();
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
self.state.update(cx, |state, cx| {
state.text_wrapper.set_font(font, font_size, cx);
state.text_wrapper.prepare_if_need(&state.text, cx);
state.disabled = self.disabled;
});
let state = self.state.read(cx);
let focused = state.focus_handle.is_focused(window) && !state.disabled;
let gap_x = match self.size {
Size::Small => px(4.),
Size::Large => px(8.),
_ => px(4.),
};
let bg = if state.disabled {
cx.theme().surface_background
} else {
cx.theme().elevated_surface_background
};
let prefix = self.prefix;
let suffix = self.suffix;
let show_clear_button = self.cleanable
&& !state.loading
&& !state.text.is_empty()
&& state.mode.is_single_line();
let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button;
div()
.id(("input", self.state.entity_id()))
.flex()
.key_context(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::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))
.when(state.mode.is_multi_line(), |this| {
this.on_action(window.listener_for(&self.state, InputState::indent_inline))
.on_action(window.listener_for(&self.state, InputState::outdent_inline))
.on_action(window.listener_for(&self.state, InputState::indent_block))
.on_action(window.listener_for(&self.state, InputState::outdent_block))
.on_action(
window.listener_for(&self.state, InputState::shift_to_new_line),
)
})
})
.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.mode.is_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::page_up))
.on_action(window.listener_for(&self.state, InputState::page_down))
})
.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_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_down(
MouseButton::Right,
window.listener_for(&self.state, InputState::on_mouse_down),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&self.state, InputState::on_mouse_up),
)
.on_mouse_up(
MouseButton::Right,
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)
.input_px(self.size)
.input_py(self.size)
.input_h(self.size)
.cursor_text()
.text_size(font_size)
.items_center()
.when(state.mode.is_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(self.bordered, |this| {
this.border_color(cx.theme().border)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(focused && self.focus_bordered, |this| {
this.border_color(cx.theme().border_focused)
})
})
})
.items_center()
.gap(gap_x)
.refine_style(&self.style)
.children(prefix)
.child(self.state.clone())
.when(has_suffix, |this| {
this.pr_2().child(
h_flex()
.id("suffix")
.gap(gap_x)
.when(self.appearance, |this| this.bg(bg))
.items_center()
.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),
)
})
}
}

View File

@@ -1,227 +0,0 @@
use std::ops::Range;
use gpui::{App, Font, LineFragment, Pixels};
use rope::Rope;
use super::rope_ext::RopeExt;
/// A line with soft wrapped lines info.
#[derive(Clone)]
pub(super) struct LineItem {
/// The original line text.
line: Rope,
/// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line).
///
/// FIXME: Here in somecase, the `line_wrapper.wrap_line` has returned different
/// like the `window.text_system().shape_text`. So, this value may not equal
/// the actual rendered lines.
wrapped_lines: Vec<Range<usize>>,
}
impl LineItem {
/// Get the bytes length of this line.
#[inline]
pub(super) fn len(&self) -> usize {
self.line.len()
}
/// Get number of soft wrapped lines of this line (include the first line).
#[inline]
pub(super) fn lines_len(&self) -> usize {
self.wrapped_lines.len()
}
/// Get the height of this line item with given line height.
pub(super) fn height(&self, line_height: Pixels) -> Pixels {
self.lines_len() as f32 * line_height
}
}
/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor.
///
/// After use lines to calculate the scroll size of the Editor.
pub(super) struct TextWrapper {
text: Rope,
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
soft_lines: usize,
font: Font,
font_size: Pixels,
/// If is none, it means the text is not wrapped
wrap_width: Option<Pixels>,
/// The lines by split \n
pub(super) lines: Vec<LineItem>,
_initialized: bool,
}
#[allow(unused)]
impl TextWrapper {
pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option<Pixels>) -> Self {
Self {
text: Rope::new(),
font,
font_size,
wrap_width,
soft_lines: 0,
lines: Vec::new(),
_initialized: false,
}
}
#[inline]
pub(super) fn set_default_text(&mut self, text: &Rope) {
self.text = text.clone();
}
/// Get the total number of lines including wrapped lines.
#[inline]
pub(super) fn len(&self) -> usize {
self.soft_lines
}
/// Get the line item by row index.
#[inline]
pub(super) fn line(&self, row: usize) -> Option<&LineItem> {
self.lines.get(row)
}
pub(super) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
if wrap_width == self.wrap_width {
return;
}
self.wrap_width = wrap_width;
self.update_all(&self.text.clone(), true, cx);
}
pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
if self.font.eq(&font) && self.font_size == font_size {
return;
}
self.font = font;
self.font_size = font_size;
self.update_all(&self.text.clone(), true, cx);
}
pub(super) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) {
if self._initialized {
return;
}
self._initialized = true;
self.update_all(text, true, cx);
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
///
/// - `changed_text`: The text [`Rope`] that has changed.
/// - `range`: The `selected_range` before change.
/// - `new_text`: The inserted text.
/// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same.
/// - `cx`: The application context.
pub(super) fn update(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
force: bool,
cx: &mut App,
) {
let mut line_wrapper = cx
.text_system()
.line_wrapper(self.font.clone(), self.font_size);
self._update(
changed_text,
range,
new_text,
force,
&mut |line_str, wrap_width| {
line_wrapper
.wrap_line(&[LineFragment::text(line_str)], wrap_width)
.collect()
},
);
}
fn _update<F>(
&mut self,
changed_text: &Rope,
range: &Range<usize>,
new_text: &Rope,
force: bool,
wrap_line: &mut F,
) where
F: FnMut(&str, Pixels) -> Vec<gpui::Boundary>,
{
if self.text.eq(changed_text) && !force {
return;
}
// Remove the old changed lines.
let start_row = self.text.offset_to_point(range.start).row as usize;
let start_row = start_row.min(self.lines.len().saturating_sub(1));
let end_row = self.text.offset_to_point(range.end).row as usize;
let end_row = end_row.min(self.lines.len().saturating_sub(1));
let rows_range = start_row..=end_row;
// To add the new lines.
let new_start_row = changed_text.offset_to_point(range.start).row as usize;
let new_start_offset = changed_text.line_start_offset(new_start_row);
let new_end_row = changed_text
.offset_to_point(range.start + new_text.len())
.row as usize;
let new_end_offset = changed_text.line_end_offset(new_end_row);
let new_range = new_start_offset..new_end_offset;
let mut new_lines = vec![];
let wrap_width = self.wrap_width;
for line in changed_text.slice(new_range).lines() {
let line_str = line.to_string();
let mut wrapped_lines = vec![];
let mut prev_boundary_ix = 0;
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
if let Some(wrap_width) = wrap_width {
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
for boundary in wrap_line(&line_str, wrap_width) {
wrapped_lines.push(prev_boundary_ix..boundary.ix);
prev_boundary_ix = boundary.ix;
}
}
// Reset of the line
if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
wrapped_lines.push(prev_boundary_ix..line.len());
}
new_lines.push(LineItem {
line: line.clone(),
wrapped_lines,
});
}
// dbg!(&new_lines.len());
// dbg!(self.lines.len());
if self.lines.is_empty() {
self.lines = new_lines;
} else {
self.lines.splice(rows_range, new_lines);
}
// dbg!(self.lines.len());
self.text = changed_text.clone();
self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum();
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
pub(crate) fn update_all(&mut self, text: &Rope, force: bool, cx: &mut App) {
self.update(text, &(0..text.len()), text, force, cx);
}
}

View File

@@ -1,312 +0,0 @@
use gpui::{
div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
};
use theme::ActiveTheme;
use crate::StyledExt;
/// A key binding tag
#[derive(IntoElement, Clone, Debug)]
pub struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
appearance: bool,
}
impl From<Keystroke> for Kbd {
fn from(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
appearance: true,
}
}
}
impl Kbd {
pub fn new(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
appearance: true,
}
}
/// Set the appearance of the keybinding.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Return the first keybinding for the given action and context.
pub fn binding_for_action(
action: &dyn Action,
context: Option<&str>,
window: &Window,
) -> Option<Self> {
let key_context = context.and_then(|context| KeyContext::parse(context).ok());
let binding = match key_context {
Some(context) => {
window.highest_precedence_binding_for_action_in_context(action, context)
}
None => window.highest_precedence_binding_for_action(action),
}?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the first keybinding for the given action and focus handle.
pub fn binding_for_action_in(
action: &dyn Action,
focus_handle: &FocusHandle,
window: &Window,
) -> Option<Self> {
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the Platform specific keybinding string by KeyStroke
///
/// macOS: https://support.apple.com/en-us/HT201236
/// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
pub fn format(key: &Keystroke) -> String {
#[cfg(target_os = "macos")]
const DIVIDER: &str = "";
#[cfg(not(target_os = "macos"))]
const DIVIDER: &str = "+";
let mut parts = vec![];
// The key map order in macOS is: ⌃⌥⇧⌘
// And in Windows is: Ctrl+Alt+Shift+Win
if key.modifiers.control {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Ctrl");
}
if key.modifiers.alt {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Alt");
}
if key.modifiers.shift {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Shift");
}
if key.modifiers.platform {
#[cfg(target_os = "macos")]
parts.push("");
#[cfg(not(target_os = "macos"))]
parts.push("Win");
}
let mut keys = String::new();
let key_str = key.key.as_str();
match key_str {
#[cfg(target_os = "macos")]
"ctrl" => keys.push('⌃'),
#[cfg(not(target_os = "macos"))]
"ctrl" => keys.push_str("Ctrl"),
#[cfg(target_os = "macos")]
"alt" => keys.push('⌥'),
#[cfg(not(target_os = "macos"))]
"alt" => keys.push_str("Alt"),
#[cfg(target_os = "macos")]
"shift" => keys.push('⇧'),
#[cfg(not(target_os = "macos"))]
"shift" => keys.push_str("Shift"),
#[cfg(target_os = "macos")]
"cmd" => keys.push('⌘'),
#[cfg(not(target_os = "macos"))]
"cmd" => keys.push_str("Win"),
#[cfg(target_os = "macos")]
"space" => keys.push_str("Space"),
#[cfg(target_os = "macos")]
"backspace" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"backspace" => keys.push_str("Backspace"),
#[cfg(target_os = "macos")]
"delete" => keys.push('⌫'),
#[cfg(not(target_os = "macos"))]
"delete" => keys.push_str("Delete"),
#[cfg(target_os = "macos")]
"escape" => keys.push('⎋'),
#[cfg(not(target_os = "macos"))]
"escape" => keys.push_str("Esc"),
#[cfg(target_os = "macos")]
"enter" => keys.push('⏎'),
#[cfg(not(target_os = "macos"))]
"enter" => keys.push_str("Enter"),
"pagedown" => keys.push_str("Page Down"),
"pageup" => keys.push_str("Page Up"),
#[cfg(target_os = "macos")]
"left" => keys.push('←'),
#[cfg(not(target_os = "macos"))]
"left" => keys.push_str("Left"),
#[cfg(target_os = "macos")]
"right" => keys.push('→'),
#[cfg(not(target_os = "macos"))]
"right" => keys.push_str("Right"),
#[cfg(target_os = "macos")]
"up" => keys.push('↑'),
#[cfg(not(target_os = "macos"))]
"up" => keys.push_str("Up"),
#[cfg(target_os = "macos")]
"down" => keys.push('↓'),
#[cfg(not(target_os = "macos"))]
"down" => keys.push_str("Down"),
_ => {
if key_str.len() == 1 {
keys.push_str(&key_str.to_uppercase());
} else {
let mut chars = key_str.chars();
if let Some(first_char) = chars.next() {
keys.push_str(&format!(
"{}{}",
first_char.to_uppercase(),
chars.collect::<String>()
));
} else {
keys.push_str(key_str);
}
}
}
}
parts.push(&keys);
parts.join(DIVIDER)
}
}
impl Styled for Kbd {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
if !self.appearance {
return Self::format(&self.stroke).into_any_element();
}
div()
.border_1()
.border_color(cx.theme().border)
.text_color(cx.theme().text_muted)
.bg(cx.theme().surface_background)
.py_0p5()
.px_1()
.min_w_5()
.text_center()
.rounded_sm()
.line_height(relative(1.))
.text_xs()
.whitespace_normal()
.flex_shrink_0()
.refine_style(&self.style)
.child(Self::format(&self.stroke))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_format() {
use gpui::Keystroke;
use super::Kbd;
if cfg!(target_os = "macos") {
assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
assert_eq!(
Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
"⌘F12"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
"⇧Page Down"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
"⇧Page Up"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"⇧Space"
);
assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
"⌥⌘⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
"⇧⌫"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
"⌃⇧⌘A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
"⌃⌥⇧⌘A"
);
} else {
assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
assert_eq!(
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
"Shift+Space"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
"Ctrl+Alt+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
"Ctrl+Alt+Shift+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
"Ctrl+Alt+Shift+Win+A"
);
assert_eq!(
Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
"Ctrl+Shift+Backspace"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
"Alt+Delete"
);
assert_eq!(
Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
"Alt+Tab"
);
}
}
}

View File

@@ -1,56 +0,0 @@
pub use element_ext::ElementExt;
pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle;
pub use icon::*;
pub use index_path::IndexPath;
pub use kbd::*;
pub use root::{Root, window_paddings};
pub use styled::*;
pub use window_ext::*;
pub use crate::Disableable;
pub mod actions;
pub mod animation;
pub mod avatar;
pub mod button;
pub mod checkbox;
pub mod divider;
pub mod dock;
pub mod group_box;
pub mod history;
pub mod indicator;
pub mod input;
pub mod list;
pub mod menu;
pub mod modal;
pub mod notification;
pub mod popover;
pub mod resizable;
pub mod scroll;
pub mod skeleton;
pub mod switch;
pub mod tab;
pub mod tooltip;
mod element_ext;
mod event;
mod focusable;
mod icon;
mod index_path;
mod kbd;
mod root;
mod styled;
mod window_ext;
/// Initialize the UI module.
///
/// This must be called before using any of the UI components.
/// You can initialize the UI module at your application's entry point.
pub fn init(cx: &mut gpui::App) {
input::init(cx);
list::init(cx);
modal::init(cx);
popover::init(cx);
menu::init(cx);
}

View File

@@ -1,221 +0,0 @@
use std::rc::Rc;
use gpui::{App, Pixels, Size};
use crate::IndexPath;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RowEntry {
Entry(IndexPath),
SectionHeader(usize),
SectionFooter(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct MeasuredEntrySize {
pub(crate) item_size: Size<Pixels>,
pub(crate) section_header_size: Size<Pixels>,
pub(crate) section_footer_size: Size<Pixels>,
}
impl RowEntry {
#[inline]
#[allow(unused)]
pub(crate) fn is_section_header(&self) -> bool {
matches!(self, RowEntry::SectionHeader(_))
}
pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool {
match self {
RowEntry::Entry(index_path) => index_path == path,
RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false,
}
}
#[allow(unused)]
pub(crate) fn index(&self) -> IndexPath {
match self {
RowEntry::Entry(index_path) => *index_path,
RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix),
RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix),
}
}
#[inline]
#[allow(unused)]
pub(crate) fn is_section_footer(&self) -> bool {
matches!(self, RowEntry::SectionFooter(_))
}
#[inline]
pub(crate) fn is_entry(&self) -> bool {
matches!(self, RowEntry::Entry(_))
}
#[inline]
#[allow(unused)]
pub(crate) fn section_ix(&self) -> Option<usize> {
match self {
RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix),
_ => None,
}
}
}
#[derive(Default, Clone)]
pub(crate) struct RowsCache {
/// Only have section's that have rows.
pub(crate) entities: Rc<Vec<RowEntry>>,
pub(crate) items_count: usize,
/// The sections, the item is number of rows in each section.
pub(crate) sections: Rc<Vec<usize>>,
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
measured_size: MeasuredEntrySize,
}
impl RowsCache {
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
self.entities.get(flatten_ix).cloned()
}
/// Returns the number of flattened rows (Includes header, item, footer).
pub(crate) fn len(&self) -> usize {
self.entities.len()
}
/// Return the number of items in the cache.
pub(crate) fn items_count(&self) -> usize {
self.items_count
}
/// Returns the index of the Entry with given path in the flattened rows.
pub(crate) fn position_of(&self, path: &IndexPath) -> Option<usize> {
self.entities
.iter()
.position(|p| p.is_entry() && p.eq_index_path(path))
}
/// Return prev row, if the row is the first in the first section, goes to the last row.
///
/// Empty rows section are skipped.
pub(crate) fn prev(&self, path: Option<IndexPath>) -> IndexPath {
let path = path.unwrap_or_default();
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(path) = self
.entities
.iter()
.take(pos)
.rev()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path
} else {
self.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
}
/// Returns the next row, if the row is the last in the last section, goes to the first row.
///
/// Empty rows section are skipped.
pub(crate) fn next(&self, path: Option<IndexPath>) -> IndexPath {
let Some(mut path) = path else {
return IndexPath::default();
};
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(next_path) = self
.entities
.iter()
.skip(pos + 1)
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path = next_path;
} else {
path = self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
path
}
pub(crate) fn prepare_if_needed<F>(
&mut self,
sections_count: usize,
measured_size: MeasuredEntrySize,
cx: &App,
rows_count_f: F,
) where
F: Fn(usize, &App) -> usize,
{
let mut new_sections = vec![];
for section_ix in 0..sections_count {
new_sections.push(rows_count_f(section_ix, cx));
}
let need_update = new_sections != *self.sections || self.measured_size != measured_size;
if !need_update {
return;
}
let mut entries_sizes = vec![];
let mut total_items_count = 0;
self.measured_size = measured_size;
self.sections = Rc::new(new_sections);
self.entities = Rc::new(
self.sections
.iter()
.enumerate()
.flat_map(|(section, items_count)| {
total_items_count += items_count;
let mut children = vec![];
if *items_count == 0 {
return children;
}
children.push(RowEntry::SectionHeader(section));
entries_sizes.push(measured_size.section_header_size);
for row in 0..*items_count {
children.push(RowEntry::Entry(IndexPath {
section,
row,
..Default::default()
}));
entries_sizes.push(measured_size.item_size);
}
children.push(RowEntry::SectionFooter(section));
entries_sizes.push(measured_size.section_footer_size);
children
})
.collect(),
);
self.entries_sizes = Rc::new(entries_sizes);
self.items_count = total_items_count;
}
}

View File

@@ -1,171 +0,0 @@
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window};
use theme::ActiveTheme;
use crate::list::loading::Loading;
use crate::list::ListState;
use crate::{h_flex, Icon, IconName, IndexPath, Selectable};
/// A delegate for the List.
#[allow(unused)]
pub trait ListDelegate: Sized + 'static {
type Item: Selectable + IntoElement;
/// When Query Input change, this method will be called.
/// You can perform search here.
fn perform_search(
&mut self,
query: &str,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Task<()> {
Task::ready(())
}
/// Return the number of sections in the list, default is 1.
///
/// Min value is 1.
fn sections_count(&self, cx: &App) -> usize {
1
}
/// Return the number of items in the section at the given index.
///
/// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items,
/// the section header and footer will also be skipped.
fn items_count(&self, section: usize, cx: &App) -> usize;
/// Render the item at the given index.
///
/// Return None will skip the item.
///
/// NOTE: Every item should have same height.
fn render_item(
&mut self,
ix: IndexPath,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item>;
/// Render the section header at the given index, default is None.
///
/// NOTE: Every header should have same height.
fn render_section_header(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Render the section footer at the given index, default is None.
///
/// NOTE: Every footer should have same height.
fn render_section_footer(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Return a Element to show when list is empty.
fn render_empty(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_color(cx.theme().text_muted.opacity(0.6))
.child(Icon::new(IconName::Inbox).size_12())
.into_any_element()
}
/// Returns Some(AnyElement) to render the initial state of the list.
///
/// This can be used to show a view for the list before the user has
/// interacted with it.
///
/// For example: The last search results, or the last selected item.
///
/// Default is None, that means no initial state.
fn render_initial(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<AnyElement> {
None
}
/// Returns the loading state to show the loading view.
fn loading(&self, cx: &App) -> bool {
false
}
/// Returns a Element to show when loading, default is built-in Skeleton
/// loading view.
fn render_loading(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
Loading
}
/// Set the selected index, just store the ix, don't confirm.
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
);
/// Set the index of the item that has been right clicked.
fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
}
/// Set the confirm and give the selected index,
/// this is means user have clicked the item or pressed Enter.
///
/// This will always to `set_selected_index` before confirm.
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
}
/// Cancel the selection, e.g.: Pressed ESC.
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
/// Return true to enable load more data when scrolling to the bottom.
///
/// Default: false
fn has_more(&self, cx: &App) -> bool {
false
}
/// Returns a threshold value (n entities), of course,
/// when scrolling to the bottom, the remaining number of rows
/// triggers `load_more`.
///
/// This should smaller than the total number of first load rows.
///
/// Default: 20 entities (section header, footer and row)
fn load_more_threshold(&self) -> usize {
20
}
/// Load more data when the table is scrolled to the bottom.
///
/// This will performed in a background task.
///
/// This is always called when the table is near the bottom,
/// so you must check if there is more data to load or lock
/// the loading state.
fn load_more(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
}

View File

@@ -1,745 +0,0 @@
use std::ops::Range;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
UniformListScrollHandle, Window, div, px, size, uniform_list,
};
use smol::Timer;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
use crate::input::{InputEvent, InputState, TextInput};
use crate::list::ListDelegate;
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
use crate::scroll::{Scrollbar, ScrollbarHandle};
use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
pub(crate) fn init(cx: &mut App) {
let context: Option<&str> = Some("List");
cx.bind_keys([
KeyBinding::new("escape", Cancel, context),
KeyBinding::new("enter", Confirm { secondary: false }, context),
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
KeyBinding::new("up", SelectUp, context),
KeyBinding::new("down", SelectDown, context),
]);
}
#[derive(Clone)]
pub enum ListEvent {
/// Move to select item.
Select(IndexPath),
/// Click on item or pressed Enter.
Confirm(IndexPath),
/// Pressed ESC to deselect the item.
Cancel,
}
struct ListOptions {
size: Size,
scrollbar_visible: bool,
search_placeholder: Option<SharedString>,
max_height: Option<Length>,
paddings: EdgesRefinement<DefiniteLength>,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
size: Size::default(),
scrollbar_visible: true,
max_height: None,
search_placeholder: None,
paddings: EdgesRefinement::default(),
}
}
}
/// The state for List.
///
/// List required all items has the same height.
pub struct ListState<D: ListDelegate> {
pub(crate) focus_handle: FocusHandle,
pub(crate) query_input: Entity<InputState>,
options: ListOptions,
delegate: D,
last_query: Option<String>,
scroll_handle: UniformListScrollHandle,
rows_cache: RowsCache,
selected_index: Option<IndexPath>,
item_to_measure_index: IndexPath,
deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>,
mouse_right_clicked_index: Option<IndexPath>,
reset_on_cancel: bool,
searchable: bool,
selectable: bool,
_search_task: Task<()>,
_load_more_task: Task<()>,
_query_input_subscription: Subscription,
}
impl<D> ListState<D>
where
D: ListDelegate,
{
pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
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 {
focus_handle: cx.focus_handle(),
options: ListOptions::default(),
delegate,
rows_cache: RowsCache::default(),
query_input,
last_query: None,
selected_index: None,
selectable: true,
searchable: false,
item_to_measure_index: IndexPath::default(),
deferred_scroll_to_index: None,
mouse_right_clicked_index: None,
scroll_handle: UniformListScrollHandle::new(),
reset_on_cancel: true,
_search_task: Task::ready(()),
_load_more_task: Task::ready(()),
_query_input_subscription,
}
}
/// Sets whether the list is searchable, default is `false`.
///
/// When `true`, there will be a search input at the top of the list.
pub fn searchable(mut self, searchable: bool) -> Self {
self.searchable = searchable;
self
}
pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
self.searchable = searchable;
cx.notify();
}
/// Sets whether the list is selectable, default is true.
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
/// Sets whether the list is selectable, default is true.
pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
self.selectable = selectable;
cx.notify();
}
pub fn delegate(&self) -> &D {
&self.delegate
}
pub fn delegate_mut(&mut self) -> &mut D {
&mut self.delegate
}
/// Focus the list, if the list is searchable, focus the search input.
pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
self.focus_handle(cx).focus(window, cx);
}
/// Return true if either the list or the search input is focused.
#[allow(dead_code)]
pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool {
self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window)
}
/// Set the selected index of the list,
/// this will also scroll to the selected item.
pub(crate) fn _set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.selectable {
return;
}
self.selected_index = ix;
self.delegate.set_selected_index(ix, window, cx);
self.scroll_to_selected_item(window, cx);
}
/// Set the selected index of the list,
/// this method will not scroll to the selected item.
pub fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.selected_index = ix;
self.delegate.set_selected_index(ix, window, cx);
}
pub fn selected_index(&self) -> Option<IndexPath> {
self.selected_index
}
/// Set the index of the item that has been right clicked.
pub fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mouse_right_clicked_index = ix;
self.delegate.set_right_clicked_index(ix, window, cx);
}
/// Returns the index of the item that has been right clicked.
pub fn right_clicked_index(&self) -> Option<IndexPath> {
self.mouse_right_clicked_index
}
/// Set a specific list item for measurement.
pub fn set_item_to_measure_index(
&mut self,
ix: IndexPath,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.item_to_measure_index = ix;
cx.notify();
}
/// Scroll to the item at the given index.
pub fn scroll_to_item(
&mut self,
ix: IndexPath,
strategy: ScrollStrategy,
_: &mut Window,
cx: &mut Context<Self>,
) {
if ix.section == 0 && ix.row == 0 {
// If the item is the first item, scroll to the top.
let mut offset = self.scroll_handle.offset();
offset.y = px(0.);
self.scroll_handle.set_offset(offset);
cx.notify();
return;
}
self.deferred_scroll_to_index = Some((ix, strategy));
cx.notify();
}
/// Get scroll handle
pub fn scroll_handle(&self) -> &UniformListScrollHandle {
&self.scroll_handle
}
pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if let Some(ix) = self.selected_index {
self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top));
cx.notify();
}
}
fn on_query_input_event(
&mut self,
state: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
InputEvent::Change => {
let text = state.read(cx).value();
let text = text.trim().to_string();
if Some(&text) == self.last_query.as_ref() {
return;
}
self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 {
self._set_selected_index(Some(IndexPath::default()), window, cx);
} else {
self._set_selected_index(None, window, cx);
}
self._search_task = cx.spawn_in(window, async move |this, window| {
search.await;
_ = this.update_in(window, |this, _, _| {
this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
this.last_query = Some(text);
});
// Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| {
this.set_searching(false, window, cx);
});
});
}
InputEvent::PressEnter { secondary } => self.on_action_confirm(
&Confirm {
secondary: *secondary,
},
window,
cx,
),
_ => {}
}
}
fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context<Self>) {
self.query_input
.update(cx, |input, cx| input.set_loading(searching, cx));
}
/// Dispatch delegate's `load_more` method when the
/// visible range is near the end.
fn load_more_if_need(
&mut self,
entities_count: usize,
visible_end: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
// FIXME: Here need void sections items count.
let threshold = self.delegate.load_more_threshold();
// Securely handle subtract logic to prevent attempt
// to subtract with overflow
if visible_end >= entities_count.saturating_sub(threshold) {
if !self.delegate.has_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);
});
});
}
}
#[allow(dead_code)]
pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self {
self.reset_on_cancel = reset;
self
}
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
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: &Confirm,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
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: IndexPath, window: &mut Window, cx: &mut Context<Self>) {
if !self.selectable {
return;
}
self.selected_index = Some(ix);
self.delegate.set_selected_index(Some(ix), window, cx);
self.scroll_to_selected_item(window, cx);
cx.emit(ListEvent::Select(ix));
cx.notify();
}
pub(crate) fn on_action_select_prev(
&mut self,
_: &SelectUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
let prev_ix = self.rows_cache.prev(self.selected_index);
self.select_item(prev_ix, window, cx);
}
pub(crate) fn on_action_select_next(
&mut self,
_: &SelectDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.rows_cache.len() == 0 {
return;
}
let next_ix = self.rows_cache.next(self.selected_index);
self.select_item(next_ix, window, cx);
}
fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let sections_count = self.delegate.sections_count(cx).max(1);
let mut measured_size = MeasuredEntrySize::default();
// Measure the item_height and section header/footer height.
let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
measured_size.item_size = self
.render_list_item(self.item_to_measure_index, window, cx)
.into_any_element()
.layout_as_root(available_space, window, cx);
if let Some(mut el) = self
.delegate
.render_section_header(0, window, cx)
.map(|r| r.into_any_element())
{
measured_size.section_header_size = el.layout_as_root(available_space, window, cx);
}
if let Some(mut el) = self
.delegate
.render_section_footer(0, window, cx)
.map(|r| r.into_any_element())
{
measured_size.section_footer_size = el.layout_as_root(available_space, window, cx);
}
self.rows_cache
.prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| {
self.delegate.items_count(section_ix, cx)
});
}
fn render_list_item(
&mut self,
ix: IndexPath,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let selectable = self.selectable;
let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false);
let mouse_right_clicked = self
.mouse_right_clicked_index
.map(|s| s.eq_row(ix))
.unwrap_or(false);
let id = SharedString::from(format!("list-item-{}", ix));
div()
.id(id)
.w_full()
.relative()
.overflow_hidden()
.children(self.delegate.render_item(ix, window, cx).map(|item| {
item.selected(selected)
.secondary_selected(mouse_right_clicked)
}))
.when(selectable, |this| {
this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
this.set_right_clicked_index(None, window, cx);
this.selected_index = Some(ix);
this.on_action_confirm(
&Confirm {
secondary: e.modifiers().secondary(),
},
window,
cx,
);
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _, window, cx| {
this.set_right_clicked_index(Some(ix), window, cx);
cx.notify();
}),
)
})
}
fn render_items(
&mut self,
items_count: usize,
entities_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let rows_cache = self.rows_cache.clone();
let scrollbar_visible = self.options.scrollbar_visible;
let scroll_handle = self.scroll_handle.clone();
v_flex()
.flex_grow_1()
.relative()
.size_full()
.when_some(self.options.max_height, |this, h| this.max_h(h))
.overflow_hidden()
.when(items_count == 0, |this| {
this.child(self.delegate.render_empty(window, cx))
})
.when(items_count > 0, {
|this| {
this.child(
uniform_list(
"virtual-list",
rows_cache.items_count(),
cx.processor(move |this, range: Range<usize>, window, cx| {
this.load_more_if_need(entities_count, range.end, window, cx);
// NOTE: Here the v_virtual_list would not able to have gap_y,
// because the section header, footer is always have rendered as a empty child item,
// even the delegate give a None result.
range
.map(|ix| {
let Some(entry) = rows_cache.get(ix) else {
return div();
};
div().children(match entry {
RowEntry::Entry(index) => Some(
this.render_list_item(index, window, cx)
.into_any_element(),
),
RowEntry::SectionHeader(section_ix) => this
.delegate_mut()
.render_section_header(section_ix, window, cx)
.map(|r| r.into_any_element()),
RowEntry::SectionFooter(section_ix) => this
.delegate_mut()
.render_section_footer(section_ix, window, cx)
.map(|r| r.into_any_element()),
})
})
.collect::<Vec<_>>()
}),
)
.when(self.options.max_height.is_some(), |this| {
this.with_sizing_behavior(ListSizingBehavior::Infer)
})
.track_scroll(&scroll_handle)
.into_any_element(),
)
}
})
.when(scrollbar_visible, |this| {
this.child(Scrollbar::vertical(&scroll_handle))
})
}
}
impl<D> Focusable for ListState<D>
where
D: ListDelegate,
{
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.searchable {
self.query_input.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl<D> EventEmitter<ListEvent> for ListState<D> where D: ListDelegate {}
impl<D> Render for ListState<D>
where
D: ListDelegate,
{
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.prepare_items_if_needed(window, cx);
// Scroll to the selected item if it is set.
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
{
self.scroll_handle.scroll_to_item(item_ix, strategy);
}
let loading = self.delegate().loading(cx);
let query_input = if self.searchable {
// sync placeholder
if let Some(placeholder) = &self.options.search_placeholder {
self.query_input.update(cx, |input, cx| {
input.set_placeholder(placeholder.clone(), window, cx);
});
}
Some(self.query_input.clone())
} else {
None
};
let loading_view = if loading {
Some(self.delegate.render_loading(window, cx).into_any_element())
} else {
None
};
let initial_view = if let Some(input) = &query_input {
if input.read(cx).value().is_empty() {
self.delegate.render_initial(window, cx)
} else {
None
}
} else {
None
};
let items_count = self.rows_cache.items_count();
let entities_count = self.rows_cache.len();
let mouse_right_clicked_index = self.mouse_right_clicked_index;
v_flex()
.key_context("List")
.id("list-state")
.track_focus(&self.focus_handle)
.size_full()
.relative()
.overflow_hidden()
.when_some(query_input, |this, input| {
this.child(
div()
.map(|this| match self.options.size {
Size::Small => this.px_1p5(),
_ => this.px_2(),
})
.border_b_1()
.border_color(cx.theme().border)
.child(
TextInput::new(&input)
.with_size(self.options.size)
.appearance(false)
.cleanable()
.p_0()
.prefix(
Icon::new(IconName::Search).text_color(cx.theme().text_muted),
),
),
)
})
.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(self.render_items(items_count, entities_count, window, cx))
}
})
// Click out to cancel right clicked row
.when(mouse_right_clicked_index.is_some(), |this| {
this.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.set_right_clicked_index(None, window, cx);
cx.notify();
}))
})
})
.children(loading_view)
}
}
/// The List element.
#[derive(IntoElement)]
pub struct List<D: ListDelegate + 'static> {
state: Entity<ListState<D>>,
style: StyleRefinement,
options: ListOptions,
}
impl<D> List<D>
where
D: ListDelegate + 'static,
{
/// Create a new List element with the given ListState entity.
pub fn new(state: &Entity<ListState<D>>) -> Self {
Self {
state: state.clone(),
style: StyleRefinement::default(),
options: ListOptions::default(),
}
}
/// Set whether the scrollbar is visible, default is `true`.
pub fn scrollbar_visible(mut self, visible: bool) -> Self {
self.options.scrollbar_visible = visible;
self
}
/// Sets the placeholder text for the search input.
pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.options.search_placeholder = Some(placeholder.into());
self
}
}
impl<D> Styled for List<D>
where
D: ListDelegate + 'static,
{
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl<D> Sizable for List<D>
where
D: ListDelegate + 'static,
{
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.options.size = size.into();
self
}
}
impl<D> RenderOnce for List<D>
where
D: ListDelegate + 'static,
{
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
// Take paddings, max_height to options, and clear them from style,
// because they would be applied to the inner virtual list.
self.options.paddings = self.style.padding.clone();
self.options.max_height = self.style.max_size.height;
self.style.padding = EdgesRefinement::default();
self.style.max_size.height = None;
self.state.update(cx, |state, _| {
state.options = self.options;
});
div()
.id("list")
.size_full()
.refine_style(&self.style)
.child(self.state.clone())
}
}

View File

@@ -1,226 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ListItemMode {
#[default]
Entry,
Separator,
}
impl ListItemMode {
#[inline]
fn is_separator(&self) -> bool {
matches!(self, ListItemMode::Separator)
}
}
#[derive(IntoElement)]
pub struct ListItem {
base: Stateful<Div>,
mode: ListItemMode,
style: StyleRefinement,
disabled: bool,
selected: bool,
secondary_selected: bool,
confirmed: bool,
check_icon: Option<Icon>,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
mode: ListItemMode::Entry,
base: h_flex().id(id),
style: StyleRefinement::default(),
disabled: false,
selected: false,
secondary_selected: false,
confirmed: false,
on_click: None,
on_mouse_enter: None,
check_icon: None,
suffix: None,
children: SmallVec::new(),
}
}
/// Set this list item to as a separator, it not able to be selected.
pub fn separator(mut self) -> Self {
self.mode = ListItemMode::Separator;
self
}
/// Set to show check icon, default is None.
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
self.check_icon = Some(icon.into());
self
}
/// Set ListItem as the selected item style.
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set ListItem as the confirmed item style, it will show a check icon.
pub fn confirmed(mut self, confirmed: bool) -> Self {
self.confirmed = confirmed;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the suffix element of the input field, for example a clear button.
pub fn suffix<F, E>(mut self, builder: F) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.suffix = Some(Box::new(move |window, cx| {
builder(window, cx).into_any_element()
}));
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_mouse_enter(
mut self,
handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_mouse_enter = Some(Box::new(handler));
self
}
}
impl Disableable for ListItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ListItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
fn secondary_selected(mut self, selected: bool) -> Self {
self.secondary_selected = selected;
self
}
}
impl Styled for ListItem {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for ListItem {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ListItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_active = self.confirmed || self.selected;
let corner_radii = self.style.corner_radii.clone();
let _selected_style = StyleRefinement {
corner_radii,
..Default::default()
};
let is_selectable = !(self.disabled || self.mode.is_separator());
self.base
.relative()
.gap_x_1()
.py_1()
.px_3()
.text_base()
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.refine_style(&self.style)
.when(is_selectable, |this| {
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
})
.when(!is_active, |this| {
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
})
.when(!is_selectable, |this| {
this.text_color(cx.theme().text_muted)
})
.child(
h_flex()
.w_full()
.items_center()
.justify_between()
.gap_x_1()
.child(div().w_full().children(self.children))
.when_some(self.check_icon, |this, icon| {
this.child(
div()
.w_5()
.items_center()
.justify_center()
.when(self.confirmed, |this| {
this.child(icon.small().text_color(cx.theme().text_muted))
}),
)
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
.map(|this| {
if is_selectable && (self.selected || self.secondary_selected) {
let bg = if self.selected {
cx.theme().ghost_element_active
} else {
cx.theme().ghost_element_background
};
this.bg(bg)
} else {
this
}
})
}
}

View File

@@ -1,34 +0,0 @@
use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled};
use super::ListItem;
use crate::skeleton::Skeleton;
use crate::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().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)
}
}

View File

@@ -1,28 +0,0 @@
pub(crate) mod cache;
mod delegate;
#[allow(clippy::module_inception)]
mod list;
mod list_item;
mod loading;
mod separator_item;
pub use delegate::*;
pub use list::*;
pub use list_item::*;
pub use separator_item::*;
use serde::{Deserialize, Serialize};
/// Settings for List.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSettings {
/// Whether to use active highlight style on ListItem, default
pub active_highlight: bool,
}
impl Default for ListSettings {
fn default() -> Self {
Self {
active_highlight: true,
}
}
}

View File

@@ -1,50 +0,0 @@
use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement};
use smallvec::SmallVec;
use crate::list::ListItem;
use crate::{Selectable, StyledExt};
pub struct ListSeparatorItem {
style: StyleRefinement,
children: SmallVec<[AnyElement; 2]>,
}
impl ListSeparatorItem {
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
children: SmallVec::new(),
}
}
}
impl Default for ListSeparatorItem {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for ListSeparatorItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Selectable for ListSeparatorItem {
fn selected(self, _: bool) -> Self {
self
}
fn is_selected(&self) -> bool {
false
}
}
impl RenderOnce for ListSeparatorItem {
fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement {
ListItem::new("separator")
.refine_style(&self.style)
.children(self.children)
.disabled(true)
}
}

View File

@@ -1,257 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
deferred, div, px,
};
use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants};
use crate::menu::PopupMenu;
use crate::{Selectable, Sizable, h_flex};
const CONTEXT: &str = "AppMenuBar";
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
]);
}
/// The application menu bar, for Windows and Linux.
pub struct AppMenuBar {
menus: Vec<Entity<AppMenu>>,
selected_index: Option<usize>,
}
impl AppMenuBar {
/// Create a new app menu bar.
pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let mut this = Self {
selected_index: None,
menus: Vec::new(),
};
this.reload(cx);
this
})
}
/// Reload the menus from the app.
pub fn reload(&mut self, cx: &mut Context<Self>) {
let menu_bar = cx.entity();
self.menus = cx
.get_menus()
.unwrap_or_default()
.iter()
.enumerate()
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx))
.collect();
self.selected_index = None;
cx.notify();
}
fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_index) = self.selected_index else {
return;
};
let new_ix = if selected_index == 0 {
self.menus.len().saturating_sub(1)
} else {
selected_index.saturating_sub(1)
};
self.set_selected_index(Some(new_ix), window, cx);
}
fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_index) = self.selected_index else {
return;
};
let new_ix = if selected_index + 1 >= self.menus.len() {
0
} else {
selected_index + 1
};
self.set_selected_index(Some(new_ix), window, cx);
}
fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.set_selected_index(None, window, cx);
}
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
self.selected_index = ix;
cx.notify();
}
#[inline]
fn has_activated_menu(&self) -> bool {
self.selected_index.is_some()
}
}
impl Render for AppMenuBar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("app-menu-bar")
.key_context(CONTEXT)
.on_action(cx.listener(Self::on_move_left))
.on_action(cx.listener(Self::on_move_right))
.on_action(cx.listener(Self::on_cancel))
.size_full()
.gap_x_1()
.overflow_x_scroll()
.children(self.menus.clone())
}
}
/// A menu in the menu bar.
pub(super) struct AppMenu {
menu_bar: Entity<AppMenuBar>,
ix: usize,
name: SharedString,
menu: OwnedMenu,
popup_menu: Option<Entity<PopupMenu>>,
_subscription: Option<Subscription>,
}
impl AppMenu {
pub(super) fn new(
ix: usize,
menu: &OwnedMenu,
menu_bar: Entity<AppMenuBar>,
cx: &mut App,
) -> Entity<Self> {
let name = menu.name.clone();
cx.new(|_| Self {
ix,
menu_bar,
name,
menu: menu.clone(),
popup_menu: None,
_subscription: None,
})
}
fn build_popup_menu(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<PopupMenu> {
let popup_menu = match self.popup_menu.as_ref() {
None => {
let items = self.menu.items.clone();
let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
menu.when_some(window.focused(cx), |this, handle| {
this.action_context(handle)
})
.with_menu_items(items, window, cx)
});
popup_menu.read(cx).focus_handle(cx).focus(window, cx);
self._subscription =
Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
self.popup_menu = Some(popup_menu.clone());
popup_menu
}
Some(menu) => menu.clone(),
};
let focus_handle = popup_menu.read(cx).focus_handle(cx);
if !focus_handle.contains_focused(window, cx) {
focus_handle.focus(window, cx);
}
popup_menu
}
fn handle_dismiss(
&mut self,
_: &Entity<PopupMenu>,
_: &DismissEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self._subscription.take();
self.popup_menu.take();
self.menu_bar.update(cx, |state, cx| {
state.on_cancel(&Cancel, window, cx);
});
}
fn handle_trigger_click(
&mut self,
_: &ClickEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix);
self.menu_bar.update(cx, |state, cx| {
let new_ix = if is_selected { None } else { Some(self.ix) };
state.set_selected_index(new_ix, window, cx);
});
}
fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
if !*hovered {
return;
}
let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
if !has_activated_menu {
return;
}
self.menu_bar.update(cx, |state, cx| {
state.set_selected_index(Some(self.ix), window, cx);
});
}
}
impl Render for AppMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu_bar = self.menu_bar.read(cx);
let is_selected = menu_bar.selected_index == Some(self.ix);
div()
.id(self.ix)
.relative()
.child(
Button::new("menu")
.small()
.py_0p5()
.compact()
.ghost()
.label(self.name.clone())
.selected(is_selected)
.on_mouse_down(MouseButton::Left, |_, window, cx| {
// Stop propagation to avoid dragging the window.
window.prevent_default();
cx.stop_propagation();
})
.on_click(cx.listener(Self::handle_trigger_click)),
)
.on_hover(cx.listener(Self::handle_hover))
.when(is_selected, |this| {
this.child(deferred(
anchored()
.anchor(gpui::Anchor::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(
div()
.size_full()
.occlude()
.top_1()
.child(self.build_popup_menu(window, cx)),
),
))
})
}
}

View File

@@ -1,323 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
Subscription, Window, anchored, deferred, div, px,
};
use crate::menu::PopupMenu;
/// A extension trait for adding a context menu to an element.
pub trait ContextMenuExt: ParentElement + Styled {
/// Add a context menu to the element.
///
/// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element.
/// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element.
fn context_menu(
self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> ContextMenu<Self>
where
Self: Sized,
{
// Generate a unique ID based on the element's memory address to ensure
// each context menu has its own state and doesn't share with others
let id = format!("context-menu-{:p}", &self as *const _);
ContextMenu::new(id, self).menu(f)
}
}
impl<E: ParentElement + Styled> ContextMenuExt for E {}
/// A context menu that can be shown on right-click.
pub struct ContextMenu<E: ParentElement + Styled + Sized> {
id: ElementId,
element: Option<E>,
#[allow(clippy::type_complexity)]
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
// This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement,
anchor: Anchor,
}
impl<E: ParentElement + Styled> ContextMenu<E> {
/// Create a new context menu with the given ID.
pub fn new(id: impl Into<ElementId>, element: E) -> Self {
Self {
id: id.into(),
element: Some(element),
menu: None,
anchor: Anchor::TopLeft,
_ignore_style: StyleRefinement::default(),
}
}
/// Build the context menu using the given builder function.
#[must_use]
fn menu<F>(mut self, builder: F) -> Self
where
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
{
self.menu = Some(Rc::new(builder));
self
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
) -> R {
window.with_optional_element_state::<ContextMenuState, _>(
Some(id),
|element_state, window| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, window, cx);
(result, Some(element_state))
},
)
}
}
impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
if let Some(element) = &mut self.element {
element.extend(elements);
}
}
}
impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
fn style(&mut self) -> &mut StyleRefinement {
if let Some(element) = &mut self.element {
element.style()
} else {
&mut self._ignore_style
}
}
}
impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
struct ContextMenuSharedState {
menu_view: Option<Entity<PopupMenu>>,
open: bool,
position: Point<Pixels>,
_subscription: Option<Subscription>,
}
pub struct ContextMenuState {
element: Option<AnyElement>,
shared_state: Rc<RefCell<ContextMenuSharedState>>,
}
impl Default for ContextMenuState {
fn default() -> Self {
Self {
element: None,
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
menu_view: None,
open: false,
position: Default::default(),
_subscription: None,
})),
}
}
}
impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
type PrepaintState = Hitbox;
type RequestLayoutState = ContextMenuState;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let anchor = self.anchor;
self.with_element_state(
id.unwrap(),
window,
cx,
|this, state: &mut ContextMenuState, window, cx| {
let (position, open) = {
let shared_state = state.shared_state.borrow();
(shared_state.position, shared_state.open)
};
let menu_view = state.shared_state.borrow().menu_view.clone();
let mut menu_element = None;
if open {
let has_menu_item = menu_view
.as_ref()
.map(|menu| !menu.read(cx).is_empty())
.unwrap_or(false);
if has_menu_item {
menu_element = Some(
deferred(
anchored().child(
div()
.w(window.bounds().size.width)
.h(window.bounds().size.height)
.on_scroll_wheel(|_, _, cx| {
cx.stop_propagation();
})
.child(
anchored()
.position(position)
.snap_to_window_with_margin(px(8.))
.anchor(anchor)
.when_some(menu_view, |this, menu| {
// Focus the menu, so that can be handle the action.
if !menu
.focus_handle(cx)
.contains_focused(window, cx)
{
menu.focus_handle(cx).focus(window, cx);
}
this.child(menu.clone())
}),
),
),
)
.with_priority(1)
.into_any(),
);
}
}
let mut element = this
.element
.take()
.expect("Element should exists.")
.children(menu_element)
.into_any_element();
let layout_id = element.request_layout(window, cx);
(
layout_id,
ContextMenuState {
element: Some(element),
..Default::default()
},
)
},
)
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
if let Some(element) = &mut request_layout.element {
element.prepaint(window, cx);
}
window.insert_hitbox(bounds, HitboxBehavior::Normal)
}
fn paint(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(element) = &mut request_layout.element {
element.paint(window, cx);
}
// Take the builder before setting up element state to avoid borrow issues
let builder = self.menu.clone();
self.with_element_state(
id.unwrap(),
window,
cx,
|_view, state: &mut ContextMenuState, window, _| {
let shared_state = state.shared_state.clone();
let hitbox = hitbox.clone();
// When right mouse click, to build content menu, and show it at the mouse position.
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase.bubble()
&& event.button == MouseButton::Right
&& hitbox.is_hovered(window)
{
{
let mut shared_state = shared_state.borrow_mut();
// Clear any existing menu view to allow immediate replacement
// Set the new position and open the menu
shared_state.menu_view = None;
shared_state._subscription = None;
shared_state.position = event.position;
shared_state.open = true;
}
// Use defer to build the menu in the next frame, avoiding race conditions
window.defer(cx, {
let shared_state = shared_state.clone();
let builder = builder.clone();
move |window, cx| {
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
let Some(build) = &builder else {
return menu;
};
build(menu, window, cx)
});
// Set up the subscription for dismiss handling
let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone();
move |_, _: &DismissEvent, window, _cx| {
shared_state.borrow_mut().open = false;
window.refresh();
}
});
// Update the shared state with the built menu and subscription
{
let mut state = shared_state.borrow_mut();
state.menu_view = Some(menu.clone());
state._subscription = Some(_subscription);
window.refresh();
}
}
});
}
});
},
);
}
}

View File

@@ -1,145 +0,0 @@
use std::rc::Rc;
use gpui::{
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window,
};
use crate::Selectable;
use crate::avatar::Avatar;
use crate::button::Button;
use crate::menu::PopupMenu;
use crate::popover::Popover;
/// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
/// Create a dropdown menu with the given items, anchored to the TopLeft corner
fn dropdown_menu(
self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
}
/// Create a dropdown menu with the given items, anchored to the given corner
fn dropdown_menu_with_anchor(
mut self,
anchor: impl Into<Anchor>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
let style = self.style().clone();
let id = self.interactivity().element_id.clone();
DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
}
}
impl DropdownMenu for Button {}
impl DropdownMenu for Avatar {}
#[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
trigger: T,
#[allow(clippy::type_complexity)]
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
}
impl<T> DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn new(
id: ElementId,
anchor: impl Into<Anchor>,
trigger: T,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
style: StyleRefinement::default(),
anchor: anchor.into(),
trigger,
builder: Rc::new(builder),
}
}
/// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the style refinement for the dropdown menu trigger.
fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.style = style;
self
}
}
#[derive(Default)]
struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>,
}
impl<T> RenderOnce for DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state =
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.trigger(self.trigger)
.trigger_style(self.style)
.anchor(self.anchor)
.content(move |_, window, cx| {
// Here is special logic to only create the PopupMenu once and reuse it.
// Because this `content` will called in every time render, so we need to store the menu
// in state to avoid recreating at every render.
//
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
let menu = match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
None => {
let builder = builder.clone();
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
// Listen for dismiss events from the PopupMenu to close the popover.
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu.clone()
}
};
menu.clone()
})
}
}

View File

@@ -1,125 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{h_flex, Disableable, StyledExt};
#[derive(IntoElement)]
pub(crate) struct MenuItemElement {
id: ElementId,
group_name: SharedString,
style: StyleRefinement,
disabled: bool,
selected: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl MenuItemElement {
/// Create a new MenuItem with the given ID and group name.
pub(crate) fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
group_name: group_name.into(),
style: StyleRefinement::default(),
disabled: false,
selected: false,
on_click: None,
on_hover: None,
children: SmallVec::new(),
}
}
/// Set ListItem as the selected item style.
pub(crate) fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set the disabled state of the MenuItem.
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set a handler for when the MenuItem is clicked.
pub(crate) fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
/// Set a handler for when the mouse enters the MenuItem.
#[allow(unused)]
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
}
impl Disableable for MenuItemElement {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Styled for MenuItemElement {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for MenuItemElement {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for MenuItemElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.group(&self.group_name)
.gap_x_1()
.p_1()
.text_sm()
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.refine_style(&self.style)
.when_some(self.on_hover, |this, on_hover| {
this.on_hover(move |hovered, window, cx| (on_hover)(hovered, window, cx))
})
.when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
})
.when(self.selected, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
})
.when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
})
.on_click(on_click)
})
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.children(self.children)
}
}

View File

@@ -1,17 +0,0 @@
use gpui::App;
mod app_menu_bar;
mod context_menu;
mod dropdown_menu;
mod menu_item;
mod popup_menu;
pub use app_menu_bar::AppMenuBar;
pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState};
pub use dropdown_menu::DropdownMenu;
pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) {
app_menu_bar::init(cx);
popup_menu::init(cx);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,540 +0,0 @@
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle,
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::scroll::ScrollableElement;
use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const CONTEXT: &str = "Modal";
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
]);
}
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
type FooterFn =
Box<dyn Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<AnyElement>>;
/// Modal button props.
pub struct ModalButtonProps {
ok_text: Option<SharedString>,
ok_variant: ButtonVariant,
cancel_text: Option<SharedString>,
cancel_variant: ButtonVariant,
}
impl Default for ModalButtonProps {
fn default() -> Self {
Self {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::Ghost { alt: false },
}
}
}
impl ModalButtonProps {
/// Sets the text of the OK button. Default is `OK`.
pub fn ok_text(mut self, ok_text: impl Into<SharedString>) -> Self {
self.ok_text = Some(ok_text.into());
self
}
/// Sets the variant of the OK button. Default is `ButtonVariant::Primary`.
pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self {
self.ok_variant = ok_variant;
self
}
/// Sets the text of the Cancel button. Default is `Cancel`.
pub fn cancel_text(mut self, cancel_text: impl Into<SharedString>) -> Self {
self.cancel_text = Some(cancel_text.into());
self
}
/// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`.
pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self {
self.cancel_variant = cancel_variant;
self
}
}
#[derive(IntoElement)]
pub struct Modal {
style: StyleRefinement,
title: Option<AnyElement>,
footer: Option<FooterFn>,
content: Div,
width: Pixels,
max_width: Option<Pixels>,
margin_top: Option<Pixels>,
on_close: OnClose,
on_ok: OnOk,
on_cancel: OnCancel,
overlay: bool,
overlay_closable: bool,
keyboard: bool,
show_close: bool,
button_props: ModalButtonProps,
/// This will be change when open the modal, the focus handle is create when open the modal.
pub focus_handle: FocusHandle,
pub layer_ix: usize,
pub overlay_visible: bool,
}
impl Modal {
pub fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
style: StyleRefinement::default(),
focus_handle: cx.focus_handle(),
title: None,
footer: None,
content: v_flex(),
margin_top: None,
width: px(380.),
max_width: None,
overlay: true,
keyboard: true,
layer_ix: 0,
overlay_visible: false,
on_close: Rc::new(|_, _, _| {}),
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: ModalButtonProps::default(),
show_close: true,
overlay_closable: true,
}
}
/// Sets the title of the modal.
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
/// Set the footer of the modal.
///
/// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
///
/// - First `RenderButtonFn` is the render function for the OK button.
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
///
/// When you set the footer, the footer will be placed default footer buttons.
pub fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
{
self.footer = Some(Box::new(move |ok, cancel, window, cx| {
footer(ok, cancel, window, cx)
.into_iter()
.map(|e| e.into_any_element())
.collect()
}));
self
}
/// Set to use confirm modal, with OK and Cancel buttons.
///
/// See also [`Self::alert`]
pub fn confirm(self) -> Self {
self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set to as a alter modal, with OK button.
///
/// See also [`Self::confirm`]
pub fn alert(self) -> Self {
self.footer(|ok, _, window, cx| vec![ok(window, cx)])
.overlay_closable(false)
.show_close(false)
}
/// Set the button props of the modal.
pub fn button_props(mut self, button_props: ModalButtonProps) -> Self {
self.button_props = button_props;
self
}
/// Sets the callback for when the modal is closed.
///
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Rc::new(on_close);
self
}
/// Sets the callback for when the modal is has been confirmed.
///
/// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_ok = Some(Rc::new(on_ok));
self
}
/// Sets the callback for when the modal is has been canceled.
///
/// The callback should return `true` to close the modal, if return `false` the modal will not be closed.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
) -> Self {
self.on_cancel = Rc::new(on_cancel);
self
}
/// Sets the false to hide close icon, default: true
pub fn show_close(mut self, show_close: bool) -> Self {
self.show_close = show_close;
self
}
/// Set the top offset of the modal, defaults to None, will use the 1/10 of the viewport height.
pub fn margin_top(mut self, margin_top: Pixels) -> Self {
self.margin_top = Some(margin_top);
self
}
/// Sets the width of the modal, defaults to 480px.
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Set the maximum width of the modal, defaults to `None`.
pub fn max_w(mut self, max_width: Pixels) -> Self {
self.max_width = Some(max_width);
self
}
/// Set the overlay of the modal, defaults to `true`.
pub fn overlay(mut self, overlay: bool) -> Self {
self.overlay = overlay;
self
}
/// Set the overlay closable of the modal, defaults to `true`.
///
/// When the overlay is clicked, the modal will be closed.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
}
/// Set whether to support keyboard esc to close the modal, defaults to `true`.
pub fn keyboard(mut self, keyboard: bool) -> Self {
self.keyboard = keyboard;
self
}
pub fn has_overlay(&self) -> bool {
self.overlay
}
}
impl ParentElement for Modal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.content.extend(elements);
}
}
impl Styled for Modal {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Modal {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
let layer_ix = self.layer_ix;
let on_close = self.on_close.clone();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_variant = self.button_props.ok_variant;
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
move |_, _| {
Button::new("ok")
.label(ok_text)
.with_variant(ok_variant)
.small()
.flex_1()
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok
&& !on_ok(&ClickEvent::default(), window, cx)
{
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_variant = self.button_props.cancel_variant;
let cancel_text = self
.button_props
.cancel_text
.unwrap_or_else(|| "Cancel".into());
move |_, _| {
Button::new("cancel")
.label(cancel_text)
.with_variant(cancel_variant)
.small()
.flex_1()
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.into_any_element()
}
});
let window_paddings = crate::root::window_paddings(window, cx);
let radius = cx.theme().radius_lg;
let view_size = window.viewport_size()
- gpui::size(
window_paddings.left + window_paddings.right,
window_paddings.top + window_paddings.bottom,
);
let bounds = Bounds {
origin: Point::default(),
size: view_size,
};
let offset_top = px(layer_ix as f32 * 16.);
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(8.);
let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
}
if let Some(pr) = self.style.padding.right {
padding_right = pr.to_pixels(self.width.into(), window.rem_size());
}
let animation = Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
anchored()
.position(point(window_paddings.left, window_paddings.top))
.snap_to_window()
.child(
div()
.id("modal")
.w(view_size.width)
.h(view_size.height)
.when(self.overlay_visible, |this| {
this.occlude().bg(cx.theme().overlay)
})
.when(self.overlay_closable, |this| {
// Only the last modal owns the `mouse down - close modal` event.
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
return this;
}
this.on_mouse_down(MouseButton::Left, {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
})
.child(
v_flex()
.id(layer_ix)
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().border.alpha(0.4))
.rounded(radius)
.when(cx.theme().shadow, |this| this.shadow_xl())
.min_h_24()
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.refine_style(&self.style)
.when(self.keyboard, |this| {
this.on_action({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_: &Cancel, window, cx| {
// FIXME:
//
// Here some Modal have no focus_handle, so it will not work will Escape key.
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
.on_action({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let has_footer = self.footer.is_some();
move |_: &Confirm, window, cx| {
if let Some(on_ok) = &on_ok {
if on_ok(&ClickEvent::default(), window, cx) {
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
} else if has_footer {
window.close_modal(cx);
}
}
})
})
// There style is high priority, can't be overridden.
.absolute()
.occlude()
.relative()
.left(x)
.top(y)
.w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_2()
.h_4()
.w_full()
.flex()
.items_center()
.justify_center()
.when_some(self.title, |this, title| {
this.h_10().font_semibold().text_center().child(title)
}),
)
.when(self.show_close, |this| {
this.child(
Button::new("close")
.icon(IconName::CloseCircleFill)
.absolute()
.top_1p5()
.right_2()
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
.color(cx.theme().ghost_element_background)
.hover(cx.theme().ghost_element_background)
.active(cx.theme().ghost_element_background),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}),
)
})
.child(
div()
.pt_px()
.w_full()
.h_auto()
.flex_1()
.overflow_hidden()
.child(
v_flex()
.pr(padding_right)
.pl(padding_left)
.size_full()
.overflow_y_scrollbar()
.child(self.content),
),
)
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
.when_some(self.footer, |this, footer| {
this.child(
h_flex()
.gap_2()
.pt(padding_left)
.pr(padding_right)
.pb(padding_left)
.pl(padding_right)
.justify_end()
.children(footer(render_ok, render_cancel, window, cx)),
)
})
.with_animation("slide-down", animation.clone(), move |this, delta| {
let y_offset = px(0.) + delta * px(30.);
// This is equivalent to `shadow_xl` with an extra opacity.
let shadow = vec![
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(20.)),
blur_radius: px(25.),
spread_radius: px(-5.),
inset: false,
},
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)),
blur_radius: px(10.),
spread_radius: px(-6.),
inset: false,
},
];
this.top(y + y_offset).shadow(shadow)
}),
)
.with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
)
}
}

View File

@@ -1,578 +0,0 @@
use std::any::TypeId;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Subscription, Window, div, px, relative,
};
use theme::ActiveTheme;
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _};
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
#[derive(Debug, Clone, Copy, Default)]
pub enum NotificationKind {
#[default]
Info,
Success,
Warning,
Error,
}
impl NotificationKind {
fn icon(&self, cx: &App) -> Icon {
match self {
Self::Info => Icon::new(IconName::Info)
.with_size(Size::Medium)
.text_color(cx.theme().icon),
Self::Success => Icon::new(IconName::CheckCircle)
.with_size(Size::Medium)
.text_color(cx.theme().icon_accent),
Self::Warning => Icon::new(IconName::Warning)
.with_size(Size::Medium)
.text_color(cx.theme().text_warning),
Self::Error => Icon::new(IconName::CloseCircle)
.with_size(Size::Medium)
.text_color(cx.theme().danger_foreground),
}
}
}
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub(crate) enum NotificationId {
Id(TypeId),
IdAndElementId(TypeId, ElementId),
}
impl From<TypeId> for NotificationId {
fn from(type_id: TypeId) -> Self {
Self::Id(type_id)
}
}
impl From<(TypeId, ElementId)> for NotificationId {
fn from((type_id, id): (TypeId, ElementId)) -> Self {
Self::IdAndElementId(type_id, id)
}
}
#[allow(clippy::type_complexity)]
/// A notification element.
pub struct Notification {
/// The id is used make the notification unique.
/// Then you push a notification with the same id, the previous notification will be replaced.
///
/// None means the notification will be added to the end of the list.
id: NotificationId,
style: StyleRefinement,
kind: Option<NotificationKind>,
title: Option<SharedString>,
message: Option<SharedString>,
icon: Option<Icon>,
autohide: bool,
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
closing: bool,
}
impl From<String> for Notification {
fn from(s: String) -> Self {
Self::new().message(s)
}
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self {
Self::new().message(s)
}
}
impl From<&'static str> for Notification {
fn from(s: &'static str) -> Self {
Self::new().message(s)
}
}
impl From<(NotificationKind, &'static str)> for Notification {
fn from((kind, content): (NotificationKind, &'static str)) -> Self {
Self::new().message(content).with_kind(kind)
}
}
impl From<(NotificationKind, SharedString)> for Notification {
fn from((kind, content): (NotificationKind, SharedString)) -> Self {
Self::new().message(content).with_kind(kind)
}
}
struct DefaultIdType;
impl Notification {
/// Create a new notification.
///
/// The default id is a random UUID.
pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
Self {
id: id.into(),
style: StyleRefinement::default(),
title: None,
message: None,
kind: None,
icon: None,
autohide: true,
action_builder: None,
content_builder: None,
on_click: None,
closing: false,
}
}
/// Set the message of the notification, default is None.
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
/// Create an info notification with the given message.
pub fn info(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Info)
}
/// Create a success notification with the given message.
pub fn success(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Success)
}
/// Create a warning notification with the given message.
pub fn warning(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Warning)
}
/// Create an error notification with the given message.
pub fn error(message: impl Into<SharedString>) -> Self {
Self::new()
.message(message)
.with_kind(NotificationKind::Error)
}
/// Set the type for unique identification of the notification.
///
/// ```rs
/// struct MyNotificationKind;
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
/// ```
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into();
self
}
/// Set the type and id of the notification, used to uniquely identify the notification.
pub fn type_id<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<T>(), key.into()).into();
self
}
/// Set the title of the notification, default is None.
///
/// If title is None, the notification will not have a title.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Set the icon of the notification.
///
/// If icon is None, the notification will use the default icon of the type.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the type of the notification, default is NotificationType::Info.
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
self.kind = Some(kind);
self
}
/// Set the auto hide of the notification, default is true.
pub fn autohide(mut self, autohide: bool) -> Self {
self.autohide = autohide;
self
}
/// Set the click callback of the notification.
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// Set the action button of the notification.
///
/// When an action is set, the notification will not autohide.
pub fn action<F>(mut self, action: F) -> Self
where
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
{
self.action_builder = Some(Rc::new(action));
self.autohide = false;
self
}
/// Dismiss the notification.
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if self.closing {
return;
}
self.closing = true;
cx.notify();
// Dismiss the notification after 0.15s to show the animation.
cx.spawn(async move |view, cx| {
cx.background_executor()
.timer(Duration::from_secs_f32(0.15))
.await;
cx.update(|cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| {
view.closing = false;
cx.emit(DismissEvent);
});
}
})
})
.detach();
}
/// Set the content of the notification.
pub fn content(
mut self,
content: impl Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement + 'static,
) -> Self {
self.content_builder = Some(Rc::new(content));
self
}
}
impl Default for Notification {
fn default() -> Self {
Self::new()
}
}
impl EventEmitter<DismissEvent> for Notification {}
impl FluentBuilder for Notification {}
impl Styled for Notification {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Render for Notification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let content = self
.content_builder
.clone()
.map(|builder| builder(self, window, cx));
let action = self.action_builder.clone().map(|builder| {
builder(self, window, cx)
.xsmall()
.primary()
.px_3()
.font_semibold()
});
let icon = match self.kind {
None => self.icon.clone(),
Some(kind) => Some(kind.icon(cx)),
};
let background = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_background,
_ => cx.theme().surface_background,
};
let text_color = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_foreground,
_ => cx.theme().text,
};
let closing = self.closing;
let has_title = self.title.is_some();
let only_message = !has_title && content.is_none() && action.is_none();
let placement = cx.theme().notification.placement;
h_flex()
.id("notification")
.group("")
.occlude()
.relative()
.w_112()
.border_1()
.border_color(cx.theme().border)
.bg(background)
.text_color(text_color)
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_md())
.p_2()
.gap_2()
.justify_start()
.items_start()
.when(only_message, |this| this.items_center())
.refine_style(&self.style)
.when_some(icon, |this, icon| {
this.child(div().flex_shrink_0().size_5().child(icon))
})
.child(
v_flex()
.flex_1()
.gap_1()
.overflow_hidden()
.when_some(self.title.clone(), |this, title| {
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
})
.when_some(self.message.clone(), |this, message| {
this.child(
div()
.text_sm()
.when(has_title, |this| this.text_color(cx.theme().text_muted))
.line_height(relative(1.3))
.child(message),
)
})
.when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| {
this.gap_2()
.child(h_flex().w_full().flex_1().justify_end().child(action))
}),
)
.child(
div()
.absolute()
.top(px(6.5))
.right(px(6.5))
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new("close")
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.dismiss(window, cx);
})),
),
)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(cx.listener(move |view, event, window, cx| {
view.dismiss(window, cx);
on_click(event, window, cx);
}))
})
.on_aux_click(cx.listener(move |view, event: &ClickEvent, window, cx| {
if event.is_middle_click() {
view.dismiss(window, cx);
}
}))
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64),
Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
move |this, delta| {
if closing {
let opacity = 1. - delta;
let that = this
.shadow_none()
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none());
match placement {
Anchor::TopRight | Anchor::BottomRight => {
let x_offset = px(0.) + delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopLeft | Anchor::BottomLeft => {
let x_offset = px(0.) - delta * px(45.);
that.left(px(0.) + x_offset)
}
Anchor::TopCenter => {
let y_offset = px(0.) - delta * px(45.);
that.top(px(0.) + y_offset)
}
Anchor::BottomCenter => {
let y_offset = px(0.) + delta * px(45.);
that.top(px(0.) + y_offset)
}
_ => that,
}
} else {
let opacity = delta;
let y_offset = match placement {
Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => {
px(-45.) + delta * px(45.)
}
Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => {
px(45.) - delta * px(45.)
}
_ => px(0.),
};
this.top(px(0.) + y_offset)
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none())
}
},
)
}
}
/// A list of notifications.
pub struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: VecDeque<Entity<Notification>>,
/// Whether the notification list is expanded.
expanded: bool,
/// Subscriptions
_subscriptions: HashMap<NotificationId, Subscription>,
}
impl NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self {
notifications: VecDeque::new(),
expanded: false,
_subscriptions: HashMap::new(),
}
}
pub fn push(
&mut self,
notification: impl Into<Notification>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let notification = notification.into();
let id = notification.id.clone();
let autohide = notification.autohide;
// Remove the notification by id, for keep unique.
self.notifications.retain(|note| note.read(cx).id != id);
let notification = cx.new(|_| notification);
self._subscriptions.insert(
id.clone(),
cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
view.notifications.retain(|note| id != note.read(cx).id);
view._subscriptions.remove(&id);
}),
);
self.notifications.push_back(notification.clone());
if autohide {
// Sleep for 5 seconds to autohide the notification
cx.spawn_in(window, async move |_this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
if let Err(err) =
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
{
log::error!("failed to auto hide notification: {:?}", err);
}
})
.detach();
}
cx.notify();
}
pub(crate) fn close(
&mut self,
id: impl Into<NotificationId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id: NotificationId = id.into();
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
n.update(cx, |note, cx| note.dismiss(window, cx))
}
cx.notify();
}
pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.notifications.clear();
cx.notify();
}
pub fn notifications(&self) -> Vec<Entity<Notification>> {
self.notifications.iter().cloned().collect()
}
}
impl Render for NotificationList {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
let size = window.viewport_size();
let items = self.notifications.iter().rev().take(10).rev().cloned();
let placement = cx.theme().notification.placement;
let margins = &cx.theme().notification.margins;
v_flex()
.id("notification-list")
.max_h(size.height)
.pt(margins.top)
.pb(margins.bottom)
.gap_3()
.when(
matches!(placement, Anchor::TopRight),
|this| this.pr(margins.right), // ignore left
)
.when(
matches!(placement, Anchor::TopLeft),
|this| this.pl(margins.left), // ignore right
)
.when(
matches!(placement, Anchor::BottomLeft),
|this| this.flex_col_reverse().pl(margins.left), // ignore right
)
.when(
matches!(placement, Anchor::BottomRight),
|this| this.flex_col_reverse().pr(margins.right), // ignore left
)
.when(matches!(placement, Anchor::BottomCenter), |this| {
this.flex_col_reverse()
})
.on_hover(cx.listener(|view, hovered, _, cx| {
view.expanded = *hovered;
cx.notify()
}))
.children(items)
}
}

View File

@@ -1,432 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
Subscription, Window, anchored, deferred, div, px,
};
use crate::actions::Cancel;
use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
const CONTEXT: &str = "Popover";
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
}
/// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
pub struct Popover {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
default_open: bool,
open: Option<bool>,
tracked_focus_handle: Option<FocusHandle>,
#[allow(clippy::type_complexity)]
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
#[allow(clippy::type_complexity)]
content: Option<
Rc<
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
+ 'static,
>,
>,
children: Vec<AnyElement>,
/// Style for trigger element.
/// This is used for hotfix the trigger element style to support w_full.
trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton,
appearance: bool,
overlay_closable: bool,
#[allow(clippy::type_complexity)]
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
}
impl Popover {
/// Create a new Popover with `view` mode.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
anchor: Anchor::TopLeft,
trigger: None,
trigger_style: None,
content: None,
tracked_focus_handle: None,
children: vec![],
mouse_button: MouseButton::Left,
appearance: true,
overlay_closable: true,
default_open: false,
open: None,
on_open_change: None,
}
}
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
///
/// This method is kept for backward compatibility with `Corner` type.
/// Internally, it converts `Corner` to `Anchor`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
/// Set the trigger element of the popover.
pub fn trigger<T>(mut self, trigger: T) -> Self
where
T: Selectable + IntoElement + 'static,
{
self.trigger = Some(Box::new(|is_open, _, _| {
let selected = trigger.is_selected();
trigger.selected(selected || is_open).into_any_element()
}));
self
}
/// Set the default open state of the popover, default is `false`.
///
/// This is only used to initialize the open state of the popover.
///
/// And please note that if you use the `open` method, this value will be ignored.
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
/// Force set the open state of the popover.
///
/// If this is set, the popover will be controlled by this value.
///
/// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
pub fn open(mut self, open: bool) -> Self {
self.open = Some(open);
self
}
/// Add a callback to be called when the open state changes.
///
/// The first `&bool` parameter is the **new open state**.
///
/// This is useful when using the `open` method to control the popover state.
pub fn on_open_change<F>(mut self, callback: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_open_change = Some(Rc::new(callback));
self
}
/// Set the style for the trigger element.
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
self
}
/// Set whether clicking outside the popover will dismiss it, default is `true`.
pub fn overlay_closable(mut self, closable: bool) -> Self {
self.overlay_closable = closable;
self
}
/// Set the content builder for content of the Popover.
///
/// This callback will called every time on render the popover.
/// So, you should avoid creating new elements or entities in the content closure.
pub fn content<F, E>(mut self, content: F) -> Self
where
E: IntoElement,
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
{
self.content = Some(Rc::new(move |state, window, cx| {
content(state, window, cx).into_any_element()
}));
self
}
/// Set whether the popover no style, default is `false`.
///
/// If no style:
///
/// - The popover will not have a bg, border, shadow, or padding.
/// - The click out of the popover will not dismiss it.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Bind the focus handle to receive focus when the popover is opened.
/// If you not set this, a new focus handle will be created for the popover to
///
/// If popover is opened, the focus will be moved to the focus handle.
pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
self.tracked_focus_handle = Some(handle.clone());
self
}
pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
match anchor {
Anchor::TopLeft => trigger_bounds.origin,
Anchor::TopCenter => trigger_bounds.top_center(),
Anchor::TopRight => trigger_bounds.top_right(),
Anchor::BottomLeft => Point {
x: trigger_bounds.origin.x,
y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
Anchor::BottomCenter => Point {
x: trigger_bounds.top_center().x,
y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
Anchor::BottomRight => Point {
x: trigger_bounds.top_right().x,
y: trigger_bounds.origin.y - trigger_bounds.size.height,
},
// Fallback for LeftCenter/RightCenter adjust as needed.
_ => trigger_bounds.origin,
}
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
pub struct PopoverState {
focus_handle: FocusHandle,
pub(crate) tracked_focus_handle: Option<FocusHandle>,
trigger_bounds: Bounds<Pixels>,
open: bool,
#[allow(clippy::type_complexity)]
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
_dismiss_subscription: Option<Subscription>,
}
impl PopoverState {
pub fn new(default_open: bool, cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
tracked_focus_handle: None,
trigger_bounds: Bounds::default(),
open: default_open,
on_open_change: None,
_dismiss_subscription: None,
}
}
/// Check if the popover is open.
pub fn is_open(&self) -> bool {
self.open
}
/// Dismiss the popover if it is open.
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
self.toggle_open(window, cx);
}
}
/// Open the popover if it is closed.
pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
self.toggle_open(window, cx);
}
}
fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open = !self.open;
if self.open {
let state = cx.entity();
let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
{
tracked_focus_handle
} else {
self.focus_handle.clone()
};
focus_handle.focus(window, cx);
self._dismiss_subscription =
Some(
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
window.refresh();
}),
);
} else {
self._dismiss_subscription = None;
}
if let Some(callback) = self.on_open_change.as_ref() {
callback(&self.open, window, cx);
}
cx.notify();
}
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.dismiss(window, cx);
}
}
impl Focusable for PopoverState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PopoverState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
impl EventEmitter<DismissEvent> for PopoverState {}
impl Popover {
pub(crate) fn render_popover<E>(
anchor: Anchor,
trigger_bounds: Bounds<Pixels>,
content: E,
_: &mut Window,
_: &mut App,
) -> Deferred
where
E: IntoElement + 'static,
{
deferred(
anchored()
.snap_to_window_with_margin(px(8.))
.anchor(anchor)
.position(Self::resolved_corner(anchor, trigger_bounds))
.child(div().relative().child(content)),
)
.with_priority(1)
}
pub(crate) fn render_popover_content(
anchor: Anchor,
appearance: bool,
_: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
v_flex()
.id("content")
.occlude()
.tab_group()
.when(appearance, |this| this.popover_style(cx).p_3())
.map(|this| match anchor {
Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(),
Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(),
Anchor::LeftCenter | Anchor::RightCenter => this.top_1(), // Fallback for centered
})
}
}
impl RenderOnce for Popover {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let force_open = self.open;
let default_open = self.default_open;
let tracked_focus_handle = self.tracked_focus_handle.clone();
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
PopoverState::new(default_open, cx)
});
state.update(cx, |state, _| {
if let Some(tracked_focus_handle) = tracked_focus_handle {
state.tracked_focus_handle = Some(tracked_focus_handle);
}
state.on_open_change = self.on_open_change.clone();
if let Some(force_open) = force_open {
state.open = force_open;
}
});
let open = state.read(cx).open;
let focus_handle = state.read(cx).focus_handle.clone();
let trigger_bounds = state.read(cx).trigger_bounds;
let Some(trigger) = self.trigger else {
return div().id("empty");
};
let parent_view_id = window.current_view();
let el = div()
.id(self.id)
.child((trigger)(open, window, cx))
.on_mouse_down(self.mouse_button, {
let state = state.clone();
move |_, window, cx| {
cx.stop_propagation();
state.update(cx, |state, cx| {
// We force set open to false to toggle it correctly.
// Because if the mouse down out will toggle open first.
state.open = open;
state.toggle_open(window, cx);
});
cx.notify(parent_view_id);
}
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, _| {
state.trigger_bounds = bounds;
})
}
});
if !open {
return el;
}
let popover_content =
Self::render_popover_content(self.anchor, self.appearance, window, cx)
.track_focus(&focus_handle)
.key_context(CONTEXT)
.on_action(window.listener_for(&state, PopoverState::on_action_cancel))
.when_some(self.content, |this, content| {
this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
})
.children(self.children)
.when(self.overlay_closable, |this| {
this.on_mouse_down_out({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
cx.notify(parent_view_id);
}
})
})
.refine_style(&self.style);
el.child(Self::render_popover(
self.anchor,
trigger_bounds,
popover_content,
window,
cx,
))
}
}

View File

@@ -1,294 +0,0 @@
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
panels: Vec<ResizablePanelState>,
sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].to_f64() == PANEL_MIN_SIZE.to_f64() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix
&& resizing_panel_ix > panel_ix
{
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
/// The `ix`` is the index of the panel to resize,
/// and the `size` is the new size for the panel.
fn resize_panel(&mut self, ix: usize, size: Pixels, _: &mut Window, cx: &mut Context<Self>) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
// Only resize the left panels.
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.to_f64()).sum::<f64>().into();
// If total size exceeds container size, adjust the main panel
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
/// Adjust panel sizes according to the container size.
///
/// When the container size changes, the panels should take up the same percentage as they did before.
fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(f32::from).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
bounds: Bounds<Pixels>,
}

View File

@@ -1,408 +0,0 @@
use std::ops::{Deref, Range};
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
};
use theme::AxisExt;
use super::{ResizableState, resizable_panel, resize_handle};
use crate::resizable::PANEL_MIN_SIZE;
use crate::{ElementExt, h_flex, v_flex};
pub enum ResizablePanelEvent {
Resized,
}
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or(
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()),
);
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
// 1. initial_size is None, to use auto size.
// 2. initial_size is Some and size is none, to use the initial size of the panel for first time render.
// 3. initial_size is Some and size is Some, use `size`.
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
// The `self.size` is None, that mean the initial size for the panel,
// so we need set `flex_shrink_0` To let it keep the initial size.
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
this.child(resize_handle(("resizable-handle", ix), self.axis).on_drag(
DragPanel,
move |drag_panel, _, _, cx| {
cx.stop_propagation();
// Set current resizing panel ix
state.update(cx, |state, _| {
state.resizing_panel_ix = Some(ix);
});
cx.new(|_| drag_panel.deref().clone())
},
))
})
}
}
#[allow(clippy::type_complexity)]
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
axis: Axis,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let current_ix = state.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
match axis {
Axis::Horizontal => {
state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx)
}
Axis::Vertical => {
state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx);
}
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let current_ix = state.read(cx).resizing_panel_ix;
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}

View File

@@ -1,226 +0,0 @@
use std::cell::Cell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, px,
};
use theme::{ActiveTheme, AxisExt};
use crate::dock::DockPlacement;
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
#[allow(clippy::type_complexity)]
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
#[allow(dead_code)]
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type PrepaintState = ();
type RequestLayoutState = AnyElement;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state = state.unwrap_or(ResizeHandleState::default());
let bg_color = if state.is_active() {
cx.theme().border_selected
} else {
cx.theme().border
};
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => {
// Special for Left Dock
// FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
this.cursor_col_resize()
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
}
_ => this
.when(axis.is_horizontal(), |this| {
this.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
((), state)
});
}
}

View File

@@ -1,483 +0,0 @@
use std::any::TypeId;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, Edges, ElementId, Entity,
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
Window, canvas, div, point, px, size,
};
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
CLIENT_SIDE_DECORATION_SHADOW,
};
use crate::input::InputState;
use crate::modal::Modal;
use crate::notification::{Notification, NotificationList};
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct ActiveModal {
focus_handle: FocusHandle,
/// The previous focused handle before opening the modal.
previous_focused_handle: Option<WeakFocusHandle>,
builder: Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>,
}
impl ActiveModal {
fn new(
focus_handle: FocusHandle,
previous_focused_handle: Option<WeakFocusHandle>,
builder: impl Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
) -> Self {
Self {
focus_handle,
previous_focused_handle,
builder: Rc::new(builder),
}
}
}
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
///
/// It is used to manage the Modal, and Notification.
pub struct Root {
/// All active models
pub(crate) active_modals: Vec<ActiveModal>,
/// Notification layer
pub(crate) notification: Entity<NotificationList>,
/// Current focused input
pub(crate) focused_input: Option<Entity<InputState>>,
/// App view
view: AnyView,
}
impl Root {
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
focused_input: None,
active_modals: Vec::new(),
notification: cx.new(|cx| NotificationList::new(window, cx)),
view,
}
}
pub fn update<F>(window: &mut Window, cx: &mut App, f: F)
where
F: FnOnce(&mut Self, &mut Window, &mut Context<Self>) + 'static,
{
if let Some(Some(root)) = window.root::<Root>() {
root.update(cx, |root, cx| f(root, window, cx));
}
}
pub fn read<'a>(window: &'a mut Window, cx: &'a mut App) -> &'a Self {
window
.root::<Root>()
.expect("The window root view should be of type `ui::Root`.")
.unwrap()
.read(cx)
}
pub fn view(&self) -> &AnyView {
&self.view
}
/// Render the notification layer.
pub fn render_notification_layer(
window: &mut Window,
cx: &mut App,
) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??;
Some(
div()
.absolute()
.top_0()
.right_0()
.child(root.read(cx).notification.clone()),
)
}
/// Render the modal layer.
pub fn render_modal_layer(
window: &mut Window,
cx: &mut App,
) -> Option<impl IntoElement + use<>> {
let root = window.root::<Root>()??;
let active_modals = root.read(cx).active_modals.clone();
if active_modals.is_empty() {
return None;
}
let mut show_overlay_ix = None;
let mut modals = active_modals
.iter()
.enumerate()
.map(|(i, active_modal)| {
let mut modal = Modal::new(window, cx);
modal = (active_modal.builder)(modal, window, cx);
// Give the modal the focus handle, because `modal` is a temporary value, is not possible to
// keep the focus handle in the modal.
//
// So we keep the focus handle in the `active_modal`, this is owned by the `Root`.
modal.focus_handle = active_modal.focus_handle.clone();
modal.layer_ix = i;
// Find the modal which one needs to show overlay.
if modal.has_overlay() {
show_overlay_ix = Some(i);
}
modal
})
.collect::<Vec<_>>();
if let Some(ix) = show_overlay_ix
&& let Some(modal) = modals.get_mut(ix)
{
modal.overlay_visible = true;
}
Some(div().children(modals))
}
/// Open a modal.
pub fn open_modal<F>(&mut self, builder: F, window: &mut Window, cx: &mut Context<'_, Self>)
where
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
{
let previous_focused_handle = window.focused(cx).map(|h| h.downgrade());
let focus_handle = cx.focus_handle();
focus_handle.focus(window, cx);
self.active_modals.push(ActiveModal::new(
focus_handle,
previous_focused_handle,
builder,
));
cx.notify();
}
/// Close the topmost modal.
pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.focused_input = None;
if let Some(handle) = self
.active_modals
.pop()
.and_then(|d| d.previous_focused_handle)
.and_then(|h| h.upgrade())
{
window.focus(&handle, cx);
}
cx.notify();
}
/// Close all modals.
pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.focused_input = None;
self.active_modals.clear();
let previous_focused_handle = self
.active_modals
.first()
.and_then(|d| d.previous_focused_handle.clone());
if let Some(handle) = previous_focused_handle.and_then(|h| h.upgrade()) {
window.focus(&handle, cx);
}
cx.notify();
}
/// Check if there are any active modals.
pub fn has_active_modals(&self) -> bool {
!self.active_modals.is_empty()
}
/// Push a notification to the notification layer.
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
where
T: Into<Notification>,
{
self.notification
.update(cx, |view, cx| view.push(note, window, cx));
cx.notify();
}
/// Clear a notification by its type.
pub fn clear_notification<T: Sized + 'static>(
&mut self,
window: &mut Window,
cx: &mut Context<'_, Root>,
) {
self.notification.update(cx, |view, cx| {
let id = TypeId::of::<T>();
view.close(id, window, cx);
});
cx.notify();
}
/// Clear a notification by its type.
pub fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
window: &mut Window,
cx: &mut Context<'_, Root>,
) {
self.notification.update(cx, |view, cx| {
let id = (TypeId::of::<T>(), key.into());
view.close(id, window, cx);
});
cx.notify();
}
/// Clear all notifications from the notification layer.
pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) {
self.notification
.update(cx, |view, cx| view.clear(window, cx));
cx.notify();
}
}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rem_size = cx.theme().font_size;
let font_family = cx.theme().font_family.clone();
let decorations = window.window_decorations();
// Set the base font size
window.set_rem_size(rem_size);
// Set the client inset (linux only)
match decorations {
Decorations::Client { .. } => window.set_client_inset(CLIENT_SIDE_DECORATION_SHADOW),
Decorations::Server => window.set_client_inset(px(0.0)),
}
div()
.id("window")
.size_full()
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.bg(gpui::transparent_black())
.child(
canvas(
|_bounds, window, _cx| {
window.insert_hitbox(
Bounds::new(
point(px(0.0), px(0.0)),
window.window_bounds().get_bounds().size,
),
HitboxBehavior::Normal,
)
},
move |_bounds, hitbox, window, _cx| {
let mouse = window.mouse_position();
let size = window.window_bounds().get_bounds().size;
let Some(edge) =
resize_edge(mouse, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
else {
return;
};
window.set_cursor_style(
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => {
CursorStyle::ResizeUpDown
}
ResizeEdge::Left | ResizeEdge::Right => {
CursorStyle::ResizeLeftRight
}
ResizeEdge::TopLeft | ResizeEdge::BottomRight => {
CursorStyle::ResizeUpLeftDownRight
}
ResizeEdge::TopRight | ResizeEdge::BottomLeft => {
CursorStyle::ResizeUpRightDownLeft
}
},
&hitbox,
);
},
)
.size_full()
.absolute(),
)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| div.pt(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.bottom, |div| div.pb(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.left, |div| div.pl(CLIENT_SIDE_DECORATION_SHADOW))
.when(!tiling.right, |div| div.pr(CLIENT_SIDE_DECORATION_SHADOW))
.on_mouse_down(MouseButton::Left, move |e, window, _cx| {
let size = window.window_bounds().get_bounds().size;
let pos = e.position;
if let Some(edge) =
resize_edge(pos, CLIENT_SIDE_DECORATION_SHADOW, size, tiling)
{
window.start_window_resize(edge)
};
}),
})
.child(
div()
.map(|div| match decorations {
Decorations::Server => div,
Decorations::Client { tiling } => div
.border_color(cx.theme().border)
.when(!(tiling.top || tiling.right), |div| {
div.rounded_tr(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.top || tiling.left), |div| {
div.rounded_tl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.right), |div| {
div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!(tiling.bottom || tiling.left), |div| {
div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(!tiling.top, |div| {
div.border_t(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.bottom, |div| {
div.border_b(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.left, |div| {
div.border_l(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.right, |div| {
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.is_tiled(), |div| {
div.shadow(vec![gpui::BoxShadow {
color: Hsla {
h: 0.,
s: 0.,
l: 0.,
a: 0.4,
},
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
spread_radius: px(0.),
offset: point(px(0.0), px(0.0)),
inset: false,
}])
}),
})
.on_mouse_move(|_e, _, cx| {
cx.stop_propagation();
})
.size_full()
.font_family(font_family)
.bg(cx.theme().surface_background)
.text_color(cx.theme().text)
.child(self.view.clone()),
)
}
}
/// Get the window paddings.
pub fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
match window.window_decorations() {
Decorations::Server => Edges::all(px(0.0)),
Decorations::Client { tiling } => {
let mut paddings = Edges::all(CLIENT_SIDE_DECORATION_SHADOW);
if tiling.top {
paddings.top = px(0.0);
}
if tiling.bottom {
paddings.bottom = px(0.0);
}
if tiling.left {
paddings.left = px(0.0);
}
if tiling.right {
paddings.right = px(0.0);
}
paddings
}
}
}
/// Get the window resize edge.
fn resize_edge(
pos: Point<Pixels>,
shadow_size: Pixels,
window_size: Size<Pixels>,
tiling: Tiling,
) -> Option<ResizeEdge> {
let bounds = Bounds::new(Point::default(), window_size).inset(shadow_size * 1.5);
if bounds.contains(&pos) {
return None;
}
let corner_size = size(shadow_size * 1.5, shadow_size * 1.5);
let top_left_bounds = Bounds::new(Point::new(px(0.), px(0.)), corner_size);
if !tiling.top && top_left_bounds.contains(&pos) {
return Some(ResizeEdge::TopLeft);
}
let top_right_bounds = Bounds::new(
Point::new(window_size.width - corner_size.width, px(0.)),
corner_size,
);
if !tiling.top && top_right_bounds.contains(&pos) {
return Some(ResizeEdge::TopRight);
}
let bottom_left_bounds = Bounds::new(
Point::new(px(0.), window_size.height - corner_size.height),
corner_size,
);
if !tiling.bottom && bottom_left_bounds.contains(&pos) {
return Some(ResizeEdge::BottomLeft);
}
let bottom_right_bounds = Bounds::new(
Point::new(
window_size.width - corner_size.width,
window_size.height - corner_size.height,
),
corner_size,
);
if !tiling.bottom && bottom_right_bounds.contains(&pos) {
return Some(ResizeEdge::BottomRight);
}
if !tiling.top && pos.y < shadow_size {
Some(ResizeEdge::Top)
} else if !tiling.bottom && pos.y > window_size.height - shadow_size {
Some(ResizeEdge::Bottom)
} else if !tiling.left && pos.x < shadow_size {
Some(ResizeEdge::Left)
} else if !tiling.right && pos.x > window_size.width - shadow_size {
Some(ResizeEdge::Right)
} else {
None
}
}

View File

@@ -1,7 +0,0 @@
mod scrollable;
mod scrollable_mask;
mod scrollbar;
pub use scrollable::*;
pub use scrollable_mask::*;
pub use scrollbar::*;

View File

@@ -1,211 +0,0 @@
use std::panic::Location;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use super::{Scrollbar, ScrollbarAxis};
use crate::StyledExt;
use crate::scroll::ScrollbarHandle;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
/// Adds a scrollbar to the element.
#[track_caller]
fn scrollbar<H: ScrollbarHandle + Clone>(
self,
scroll_handle: &H,
axis: impl Into<ScrollbarAxis>,
) -> Self {
self.child(ScrollbarLayer {
id: "scrollbar_layer".into(),
axis: axis.into(),
scroll_handle: Rc::new(scroll_handle.clone()),
})
}
/// Adds a vertical scrollbar to the element.
#[track_caller]
fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
}
/// Adds a horizontal scrollbar to the element.
#[track_caller]
fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars.
#[track_caller]
fn overflow_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Both)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar.
#[track_caller]
fn overflow_x_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Horizontal)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
#[track_caller]
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Vertical)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
id: ElementId,
element: E,
axis: ScrollbarAxis,
}
impl<E> Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
#[track_caller]
fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
let caller = Location::caller();
Self {
id: ElementId::CodeLocation(*caller),
element,
axis: axis.into(),
}
}
}
impl<E> Styled for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.style()
}
}
impl<E> ParentElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.element.extend(elements)
}
}
impl InteractiveElement for Scrollable<Div> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl InteractiveElement for Scrollable<Stateful<Div>> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl<E> RenderOnce for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element + 'static,
{
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let scroll_handle = window
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone();
// Inherit the size from the element style.
let style = StyleRefinement {
size: self.element.style().size.clone(),
..Default::default()
};
div()
.id(self.id)
.size_full()
.refine_style(&style)
.relative()
.child(
div()
.id("scroll-area")
.flex()
.size_full()
.track_scroll(&scroll_handle)
.map(|this| match self.axis {
ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
ScrollbarAxis::Both => this.overflow_scroll(),
})
.child(
self.element
// Refine element size to `flex_1`.
.size_auto()
.flex_1(),
),
)
.child(render_scrollbar(
"scrollbar",
&scroll_handle,
self.axis,
window,
cx,
))
}
}
impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E>
where
E: ParentElement + Styled + Element,
Self: InteractiveElement,
{
}
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: Rc<H>,
}
impl<H> RenderOnce for ScrollbarLayer<H>
where
H: ScrollbarHandle + Clone + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
}
}
#[inline]
#[track_caller]
fn render_scrollbar<H: ScrollbarHandle + Clone>(
id: impl Into<ElementId>,
scroll_handle: &H,
axis: ScrollbarAxis,
window: &mut Window,
cx: &mut App,
) -> Div {
// Do not render scrollbar when inspector is picking elements,
// to allow us to pick the background elements.
let is_inspector_picking = window.is_inspector_picking(cx);
if is_inspector_picking {
return div();
}
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::new(scroll_handle).id(id).axis(axis))
}

View File

@@ -1,169 +0,0 @@
use gpui::{
App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative,
};
use theme::AxisExt;
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
///
/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
/// You can use this `scroll_handle` to control what you want to scroll.
/// This is only can handle once axis scrolling.
pub struct ScrollableMask {
view_id: EntityId,
axis: Axis,
scroll_handle: ScrollHandle,
debug: Option<Hsla>,
}
impl ScrollableMask {
/// Create a new scrollable mask element.
pub fn new(view_id: EntityId, axis: Axis, scroll_handle: &ScrollHandle) -> Self {
Self {
view_id,
scroll_handle: scroll_handle.clone(),
axis,
debug: None,
}
}
/// Enable the debug border, to show the mask bounds.
#[allow(dead_code)]
pub fn debug(mut self) -> Self {
self.debug = Some(gpui::yellow());
self
}
}
impl IntoElement for ScrollableMask {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ScrollableMask {
type PrepaintState = Hitbox;
type RequestLayoutState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let style = Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
_: &mut App,
) -> Self::PrepaintState {
// Move y to bounds height to cover the parent view.
let cover_bounds = Bounds {
origin: Point {
x: bounds.origin.x,
y: bounds.origin.y - bounds.size.height,
},
size: bounds.size,
};
window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal)
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let line_height = window.line_height();
let bounds = hitbox.bounds;
window.with_content_mask(Some(ContentMask { bounds }), |window| {
if let Some(color) = self.debug {
window.paint_quad(PaintQuad {
bounds,
border_widths: Edges::all(px(1.0)),
border_color: color,
background: gpui::transparent_white().into(),
corner_radii: Corners::all(px(0.)),
border_style: BorderStyle::default(),
});
}
window.on_mouse_event({
let view_id = self.view_id;
let is_horizontal = self.axis.is_horizontal();
let scroll_handle = self.scroll_handle.clone();
let hitbox = hitbox.clone();
let mouse_position = window.mouse_position();
let last_offset = scroll_handle.offset();
move |event: &ScrollWheelEvent, phase, window, cx| {
if bounds.contains(&mouse_position)
&& phase.bubble()
&& hitbox.is_hovered(window)
{
let mut offset = scroll_handle.offset();
let mut delta = event.delta.pixel_delta(line_height);
// Limit for only one way scrolling at same time.
// When use MacBook touchpad we may get both x and y delta,
// only allows the one that more to scroll.
if !delta.x.is_zero() && !delta.y.is_zero() {
if delta.x.abs() > delta.y.abs() {
delta.y = px(0.);
} else {
delta.x = px(0.);
}
}
if is_horizontal {
offset.x += delta.x;
} else {
offset.y += delta.y;
}
if last_offset != offset {
scroll_handle.set_offset(offset);
cx.notify(view_id);
cx.stop_propagation();
}
}
}
});
});
}
}

View File

@@ -1,945 +0,0 @@
use std::cell::Cell;
use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc;
use std::time::{Duration, Instant};
use gpui::{
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
const WIDTH: Pixels = px(1. * 2. + 8.);
const MIN_THUMB_SIZE: f32 = 48.;
const THUMB_WIDTH: Pixels = px(6.);
const THUMB_RADIUS: Pixels = px(6. / 2.);
const THUMB_INSET: Pixels = px(1.);
const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
const THUMB_ACTIVE_INSET: Pixels = px(1.);
const FADE_OUT_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.0;
/// A trait for scroll handles that can get and set offset.
pub trait ScrollbarHandle: 'static {
/// Get the current offset of the scroll handle.
fn offset(&self) -> Point<Pixels>;
/// Set the offset of the scroll handle.
fn set_offset(&self, offset: Point<Pixels>);
/// The full size of the content, including padding.
fn content_size(&self) -> Size<Pixels>;
/// Called when start dragging the scrollbar thumb.
fn start_drag(&self) {}
/// Called when end dragging the scrollbar thumb.
fn end_drag(&self) {}
}
impl ScrollbarHandle for ScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset(offset);
}
fn content_size(&self) -> Size<Pixels> {
Size::from(self.max_offset()) + self.bounds().size
}
}
impl ScrollbarHandle for UniformListScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.0.borrow().base_handle.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.0.borrow_mut().base_handle.set_offset(offset)
}
fn content_size(&self) -> Size<Pixels> {
let base_handle = &self.0.borrow().base_handle;
Size::from(base_handle.max_offset()) + base_handle.bounds().size
}
}
impl ScrollbarHandle for ListState {
fn offset(&self) -> Point<Pixels> {
self.scroll_px_offset_for_scrollbar()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset_from_scrollbar(offset);
}
fn content_size(&self) -> Size<Pixels> {
Size::from(self.max_offset_for_scrollbar()) + self.viewport_bounds().size
}
fn start_drag(&self) {
self.scrollbar_drag_started();
}
fn end_drag(&self) {
self.scrollbar_drag_ended();
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
struct ScrollbarStateInner {
hovered_axis: Option<Axis>,
hovered_on_thumb: Option<Axis>,
dragged_axis: Option<Axis>,
drag_pos: Point<Pixels>,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
// Last update offset
last_update: Instant,
idle_timer_scheduled: bool,
}
impl Default for ScrollbarState {
fn default() -> Self {
Self(Rc::new(Cell::new(ScrollbarStateInner {
hovered_axis: None,
hovered_on_thumb: None,
dragged_axis: None,
drag_pos: point(px(0.), px(0.)),
last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None,
last_update: Instant::now(),
idle_timer_scheduled: false,
})))
}
}
impl Deref for ScrollbarState {
type Target = Rc<Cell<ScrollbarStateInner>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl ScrollbarStateInner {
fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
let mut state = *self;
if axis.is_vertical() {
state.drag_pos.y = pos.y;
} else {
state.drag_pos.x = pos.x;
}
state.dragged_axis = Some(axis);
state
}
fn with_unset_drag_pos(&self) -> Self {
let mut state = *self;
state.dragged_axis = None;
state
}
fn with_hovered(&self, axis: Option<Axis>) -> Self {
let mut state = *self;
state.hovered_axis = axis;
if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
let mut state = *self;
state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
state
}
fn with_last_scroll(
&self,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
) -> Self {
let mut state = *self;
state.last_scroll_offset = last_scroll_offset;
state.last_scroll_time = last_scroll_time;
state
}
fn with_last_scroll_time(&self, t: Option<Instant>) -> Self {
let mut state = *self;
state.last_scroll_time = t;
state
}
fn with_last_update(&self, t: Instant) -> Self {
let mut state = *self;
state.last_update = t;
state
}
fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
let mut state = *self;
state.idle_timer_scheduled = scheduled;
state
}
fn is_scrollbar_visible(&self) -> bool {
// On drag
if self.dragged_axis.is_some() {
return true;
}
if let Some(last_time) = self.last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
elapsed < FADE_OUT_DURATION
} else {
false
}
}
}
/// Scrollbar axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarAxis {
/// Vertical scrollbar.
Vertical,
/// Horizontal scrollbar.
Horizontal,
/// Show both vertical and horizontal scrollbars.
Both,
}
impl From<Axis> for ScrollbarAxis {
fn from(axis: Axis) -> Self {
match axis {
Axis::Vertical => Self::Vertical,
Axis::Horizontal => Self::Horizontal,
}
}
}
impl ScrollbarAxis {
/// Return true if the scrollbar axis is vertical.
#[inline]
pub fn is_vertical(&self) -> bool {
matches!(self, Self::Vertical)
}
/// Return true if the scrollbar axis is horizontal.
#[inline]
pub fn is_horizontal(&self) -> bool {
matches!(self, Self::Horizontal)
}
/// Return true if the scrollbar axis is both vertical and horizontal.
#[inline]
pub fn is_both(&self) -> bool {
matches!(self, Self::Both)
}
/// Return true if the scrollbar has vertical axis.
#[inline]
pub fn has_vertical(&self) -> bool {
matches!(self, Self::Vertical | Self::Both)
}
/// Return true if the scrollbar has horizontal axis.
#[inline]
pub fn has_horizontal(&self) -> bool {
matches!(self, Self::Horizontal | Self::Both)
}
#[inline]
fn all(&self) -> Vec<Axis> {
match self {
Self::Vertical => vec![Axis::Vertical],
Self::Horizontal => vec![Axis::Horizontal],
// This should keep Horizontal first, Vertical is the primary axis
// if Vertical not need display, then Horizontal will not keep right margin.
Self::Both => vec![Axis::Horizontal, Axis::Vertical],
}
}
}
/// Scrollbar control for scroll-area or a uniform-list.
pub struct Scrollbar {
pub(crate) id: ElementId,
axis: ScrollbarAxis,
scrollbar_mode: Option<ScrollbarMode>,
scroll_handle: Rc<dyn ScrollbarHandle>,
scroll_size: Option<Size<Pixels>>,
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// This is used to limit the update rate of the scrollbar when it is
/// being dragged for some complex interactions for reducing CPU usage.
max_fps: usize,
}
impl Scrollbar {
/// Create a new scrollbar.
///
/// This will have both vertical and horizontal scrollbars.
#[track_caller]
pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
let caller = Location::caller();
Self {
id: ElementId::CodeLocation(*caller),
axis: ScrollbarAxis::Both,
scrollbar_mode: None,
scroll_handle: Rc::new(scroll_handle.clone()),
max_fps: 120,
scroll_size: None,
}
}
/// Create with horizontal scrollbar.
#[track_caller]
pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
}
/// Create with vertical scrollbar.
#[track_caller]
pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
}
/// Set a specific element id, default is the [`Location::caller`].
///
/// NOTE: In most cases, you don't need to set a specific id for scrollbar.
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = id.into();
self
}
/// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`.
pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self {
self.scrollbar_mode = Some(mode);
self
}
/// Set a special scroll size of the content area, default is None.
///
/// Default will sync the `content_size` from `scroll_handle`.
pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
self.scroll_size = Some(scroll_size);
self
}
/// Set scrollbar axis.
pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
self.axis = axis.into();
self
}
/// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// If you have very high CPU usage, consider reducing this value to improve performance.
///
/// Available values: 30..120
#[allow(dead_code)]
pub(crate) fn max_fps(mut self, max_fps: usize) -> Self {
self.max_fps = max_fps.clamp(30, 120);
self
}
// Get the width of the scrollbar.
#[allow(dead_code)]
pub(crate) const fn width() -> Pixels {
WIDTH
}
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_hover_background,
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_hover_background,
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
gpui::transparent_black(),
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let (width, inset, radius) = match scrollbar_mode {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
};
(
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_track_background,
gpui::transparent_black(),
width,
inset,
radius,
)
}
fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always);
let (width, inset, radius) = match scrollbar_mode {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
};
(
gpui::transparent_black(),
gpui::transparent_black(),
gpui::transparent_black(),
width,
inset,
radius,
)
}
}
impl IntoElement for Scrollbar {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
#[doc(hidden)]
pub struct PrepaintState {
hitbox: Hitbox,
scrollbar_state: ScrollbarState,
states: Vec<AxisPrepaintState>,
}
#[doc(hidden)]
pub struct AxisPrepaintState {
axis: Axis,
bar_hitbox: Hitbox,
bounds: Bounds<Pixels>,
radius: Pixels,
bg: Hsla,
border: Hsla,
thumb_bounds: Bounds<Pixels>,
// Bounds of thumb to be rendered.
thumb_fill_bounds: Bounds<Pixels>,
thumb_bg: Hsla,
scroll_size: Pixels,
container_size: Pixels,
thumb_size: Pixels,
margin_end: Pixels,
}
impl Element for Scrollbar {
type PrepaintState = PrepaintState;
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let style = Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
window.insert_hitbox(bounds, HitboxBehavior::Normal)
});
let state = window
.use_state(cx, |_, _| ScrollbarState::default())
.read(cx)
.clone();
let mut states = vec![];
let mut has_both = self.axis.is_both();
let scroll_size = self
.scroll_size
.unwrap_or(self.scroll_handle.content_size());
for axis in self.axis.all().into_iter() {
let is_vertical = axis.is_vertical();
let (scroll_area_size, container_size, scroll_position) = if is_vertical {
(
scroll_size.height,
hitbox.size.height,
self.scroll_handle.offset().y,
)
} else {
(
scroll_size.width,
hitbox.size.width,
self.scroll_handle.offset().x,
)
};
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
let margin_end = if has_both && !is_vertical {
WIDTH
} else {
px(0.)
};
// Hide scrollbar, if the scroll area is smaller than the container.
if scroll_area_size <= container_size {
has_both = false;
continue;
}
let thumb_length =
(container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
let thumb_start = -(scroll_position / (scroll_area_size - container_size)
* (container_size - margin_end - thumb_length));
let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
let bounds = Bounds {
origin: if is_vertical {
point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
} else {
point(
hitbox.origin.x,
hitbox.origin.y + hitbox.size.height - WIDTH,
)
},
size: gpui::Size {
width: if is_vertical {
WIDTH
} else {
hitbox.size.width
},
height: if is_vertical {
hitbox.size.height
} else {
WIDTH
},
},
};
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let is_always_to_show = scrollbar_show.is_always();
let is_hover_to_show = scrollbar_show.is_hover();
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset();
let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
if state.get().dragged_axis == Some(axis) {
Self::style_for_active(cx)
} else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
}
} else if is_offset_changed {
self.style_for_normal(cx)
} else if is_always_to_show {
if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
}
} else {
let mut idle_state = self.style_for_idle(cx);
// Delay 2s to fade out the scrollbar thumb (in 1s)
if let Some(last_time) = state.get().last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
if is_hovered_on_bar {
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
idle_state = if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
};
} else if elapsed < FADE_OUT_DELAY {
idle_state.0 = cx.theme().scrollbar_thumb_background;
if !state.get().idle_timer_scheduled {
let state = state.clone();
state.set(state.get().with_idle_timer_scheduled(true));
let current_view = window.current_view();
let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed);
window
.spawn(cx, async move |cx| {
cx.background_executor().timer(next_delay).await;
state.set(state.get().with_idle_timer_scheduled(false));
cx.update(|_, cx| cx.notify(current_view)).ok();
})
.detach();
}
} else if elapsed < FADE_OUT_DURATION {
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity);
window.request_animation_frame();
}
}
idle_state
};
// The clickable area of the thumb
let thumb_length = thumb_end - thumb_start - inset * 2;
let thumb_bounds = if is_vertical {
Bounds::from_anchor_and_size(
Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start),
size(WIDTH, thumb_length),
)
} else {
Bounds::from_anchor_and_size(
Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, WIDTH),
)
};
// The actual render area of the thumb
let thumb_fill_bounds = if is_vertical {
Bounds::from_anchor_and_size(
Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start),
size(thumb_width, thumb_length),
)
} else {
Bounds::from_anchor_and_size(
Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, thumb_width),
)
};
let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
});
states.push(AxisPrepaintState {
axis,
bar_hitbox,
bounds,
radius,
bg: bar_bg,
border: bar_border,
thumb_bounds,
thumb_fill_bounds,
thumb_bg,
scroll_size: scroll_area_size,
container_size,
thumb_size: thumb_length,
margin_end,
})
}
PrepaintState {
hitbox,
states,
scrollbar_state: state,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let scrollbar_state = &prepaint.scrollbar_state;
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let view_id = window.current_view();
let hitbox_bounds = prepaint.hitbox.bounds;
let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
let is_hover_to_show = scrollbar_show.is_hover();
// Update last_scroll_time when offset is changed.
if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
scrollbar_state.set(
scrollbar_state
.get()
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
);
cx.notify(view_id);
}
window.with_content_mask(
Some(ContentMask {
bounds: hitbox_bounds,
}),
|window| {
for state in prepaint.states.iter() {
let axis = state.axis;
let mut radius = state.radius;
if cx.theme().radius.is_zero() {
radius = px(0.);
}
let bounds = state.bounds;
let thumb_bounds = state.thumb_bounds;
let scroll_area_size = state.scroll_size;
let container_size = state.container_size;
let thumb_size = state.thumb_size;
let margin_end = state.margin_end;
let is_vertical = axis.is_vertical();
window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
window.paint_layer(hitbox_bounds, |cx| {
cx.paint_quad(fill(state.bounds, state.bg));
cx.paint_quad(PaintQuad {
bounds,
corner_radii: (0.).into(),
background: gpui::transparent_black().into(),
border_widths: Edges {
top: px(0.),
right: px(0.),
bottom: px(0.),
left: px(0.),
},
border_color: state.border,
border_style: BorderStyle::default(),
});
cx.paint_quad(
fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius),
);
});
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |event: &ScrollWheelEvent, phase, _, cx| {
if phase.bubble()
&& hitbox_bounds.contains(&event.position)
&& scroll_handle.offset() != state.get().last_scroll_offset
{
state.set(state.get().with_last_scroll(
scroll_handle.offset(),
Some(Instant::now()),
));
cx.notify(view_id);
}
}
});
let safe_range = (-scroll_area_size + container_size)..px(0.);
if is_hover_to_show || is_visible {
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |event: &MouseDownEvent, phase, _, cx| {
if phase.bubble() && bounds.contains(&event.position) {
cx.stop_propagation();
if thumb_bounds.contains(&event.position) {
// click on the thumb bar, set the drag position
let pos = event.position - thumb_bounds.origin;
scroll_handle.start_drag();
state.set(state.get().with_drag_pos(axis, pos));
cx.notify(view_id);
} else {
// click on the scrollbar, jump to the position
// Set the thumb bar center to the click position
let offset = scroll_handle.offset();
let percentage = if is_vertical {
(event.position.y - thumb_size / 2. - bounds.origin.y)
/ (bounds.size.height - thumb_size)
} else {
(event.position.x - thumb_size / 2. - bounds.origin.x)
/ (bounds.size.width - thumb_size)
}
.min(1.);
if is_vertical {
scroll_handle.set_offset(point(
offset.x,
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
));
} else {
scroll_handle.set_offset(point(
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
offset.y,
));
}
}
}
}
});
}
window.on_mouse_event({
let scroll_handle = self.scroll_handle.clone();
let state = scrollbar_state.clone();
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
move |event: &MouseMoveEvent, _, _, cx| {
let mut notify = false;
// When is hover to show mode or it was visible,
// we need to update the hovered state and increase the last_scroll_time.
let need_hover_to_update = is_hover_to_show || is_visible;
// Update hovered state for scrollbar
if bounds.contains(&event.position) && need_hover_to_update {
state.set(state.get().with_hovered(Some(axis)));
if state.get().hovered_axis != Some(axis) {
notify = true;
}
} else if state.get().hovered_axis == Some(axis) {
state.set(state.get().with_hovered(None));
notify = true;
}
// Update hovered state for scrollbar thumb
if thumb_bounds.contains(&event.position) {
if state.get().hovered_on_thumb != Some(axis) {
state.set(state.get().with_hovered_on_thumb(Some(axis)));
notify = true;
}
} else if state.get().hovered_on_thumb == Some(axis) {
state.set(state.get().with_hovered_on_thumb(None));
notify = true;
}
// Move thumb position on dragging
if state.get().dragged_axis == Some(axis) && event.dragging() {
// Stop the event propagation to avoid selecting text or other side effects.
cx.stop_propagation();
// drag_pos is the position of the mouse down event
// We need to keep the thumb bar still at the origin down position
let drag_pos = state.get().drag_pos;
let percentage = (if is_vertical {
(event.position.y - drag_pos.y - bounds.origin.y)
/ (bounds.size.height - thumb_size)
} else {
(event.position.x - drag_pos.x - bounds.origin.x)
/ (bounds.size.width - thumb_size - margin_end)
})
.clamp(0., 1.);
let offset = if is_vertical {
point(
scroll_handle.offset().x,
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
)
} else {
point(
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
scroll_handle.offset().y,
)
};
if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|| (scroll_handle.offset().x - offset.x).abs() > px(1.)
{
// Limit update rate
if state.get().last_update.elapsed() > max_fps_duration {
scroll_handle.set_offset(offset);
state.set(state.get().with_last_update(Instant::now()));
notify = true;
}
}
}
if notify {
cx.notify(view_id);
}
}
});
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |_event: &MouseUpEvent, phase, _, cx| {
if phase.bubble() {
scroll_handle.end_drag();
state.set(state.get().with_unset_drag_pos());
cx.notify(view_id);
}
}
});
}
},
);
}
}

View File

@@ -1,68 +0,0 @@
use std::time::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
Styled,
};
use theme::ActiveTheme;
use crate::StyledExt;
#[derive(IntoElement)]
pub struct Skeleton {
style: StyleRefinement,
secondary: bool,
}
impl Skeleton {
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
secondary: false,
}
}
pub fn secondary(mut self) -> Self {
self.secondary = true;
self
}
}
impl Default for Skeleton {
fn default() -> Self {
Self::new()
}
}
impl Styled for Skeleton {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
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()
.w_full()
.h_4()
.rounded_md()
.refine_style(&self.style)
.bg(color)
.with_animation(
"skeleton",
Animation::new(Duration::from_secs(3))
.repeat()
.with_easing(bounce(ease_in_out)),
move |this, delta| {
let v = 1.0 - delta * 0.5;
this.opacity(v)
},
)
}
}

View File

@@ -1,275 +0,0 @@
use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
use serde::{Deserialize, Serialize};
use theme::ActiveTheme;
/// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div {
div().h_flex()
}
/// Returns a `Div` as vertical flex layout.
pub fn v_flex() -> Div {
div().v_flex()
}
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {
($fn:ident, $const:ident) => {
/// [docs](https://tailwindcss.com/docs/font-weight)
fn $fn(self) -> Self {
self.font_weight(gpui::FontWeight::$const)
}
};
}
/// Extends [`gpui::Styled`] with specific styling methods.
pub trait StyledExt: Styled + Sized {
/// Refine the style of this element, applying the given style refinement.
fn refine_style(mut self, style: &StyleRefinement) -> Self {
self.style().refine(style);
self
}
/// Apply self into a horizontal flex layout.
#[inline]
fn h_flex(self) -> Self {
self.flex().flex_row().items_center()
}
/// Apply self into a vertical flex layout.
#[inline]
fn v_flex(self) -> Self {
self.flex().flex_col()
}
/// Apply paddings to the element.
fn paddings<L>(self, paddings: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let paddings = paddings.into();
self.pt(paddings.top.into())
.pb(paddings.bottom.into())
.pl(paddings.left.into())
.pr(paddings.right.into())
}
/// Apply margins to the element.
fn margins<L>(self, margins: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let margins = margins.into();
self.mt(margins.top.into())
.mb(margins.bottom.into())
.ml(margins.left.into())
.mr(margins.right.into())
}
font_weight!(font_thin, THIN);
font_weight!(font_extralight, EXTRA_LIGHT);
font_weight!(font_light, LIGHT);
font_weight!(font_normal, NORMAL);
font_weight!(font_medium, MEDIUM);
font_weight!(font_semibold, SEMIBOLD);
font_weight!(font_bold, BOLD);
font_weight!(font_extrabold, EXTRA_BOLD);
font_weight!(font_black, BLACK);
/// Set as Popover style
#[inline]
fn popover_style(self, cx: &mut App) -> Self {
self.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius)
}
}
impl<E: Styled> StyledExt for E {}
/// A size for elements.
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub enum Size {
Size(Pixels),
XSmall,
Small,
#[default]
Medium,
Large,
}
impl From<Pixels> for Size {
fn from(size: Pixels) -> Self {
Size::Size(size)
}
}
/// A trait for defining element that can be selected.
pub trait Selectable: Sized {
/// Set the selected state of the element.
fn selected(self, selected: bool) -> Self;
/// Returns true if the element is selected.
fn is_selected(&self) -> bool;
/// Set is the element mouse right clicked, default do nothing.
fn secondary_selected(self, _: bool) -> Self {
self
}
}
/// A trait for defining element that can be disabled.
pub trait Disableable {
/// Set the disabled state of the element.
fn disabled(self, disabled: bool) -> Self;
}
/// A trait for setting the size of an element.
pub trait Sizable: Sized {
/// Set the ui::Size of this element.
///
/// Also can receive a `ButtonSize` to convert to `IconSize`,
/// Or a `Pixels` to set a custom size: `px(30.)`
fn with_size(self, size: impl Into<Size>) -> Self;
/// Set to Size::XSmall
fn xsmall(self) -> Self {
self.with_size(Size::XSmall)
}
/// Set to Size::Small
fn small(self) -> Self {
self.with_size(Size::Small)
}
/// Set to Size::Medium
fn medium(self) -> Self {
self.with_size(Size::Medium)
}
/// Set to Size::Large
fn large(self) -> Self {
self.with_size(Size::Large)
}
}
#[allow(unused)]
pub trait StyleSized<T: Styled> {
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;
fn input_px(self, size: Size) -> Self;
fn input_py(self, size: Size) -> Self;
fn input_h(self, size: Size) -> Self;
fn list_size(self, size: Size) -> Self;
fn list_px(self, size: Size) -> Self;
fn list_py(self, size: Size) -> Self;
/// Apply size with the given `Size`.
fn size_with(self, size: Size) -> Self;
}
impl<T: Styled> StyleSized<T> for T {
fn input_font_size(self, size: Size) -> Self {
match size {
Size::XSmall => self.text_xs(),
Size::Small => self.text_sm(),
Size::Medium => self.text_base(),
Size::Large => self.text_lg(),
Size::Size(size) => self.text_size(size),
}
}
fn input_size(self, size: Size) -> Self {
self.input_px(size).input_py(size).input_h(size)
}
fn input_pl(self, size: Size) -> Self {
match size {
Size::XSmall => self.pl_1(),
Size::Medium => self.pl_3(),
Size::Large => self.pl_5(),
_ => self.pl_2(),
}
}
fn input_pr(self, size: Size) -> Self {
match size {
Size::XSmall => self.pr_1(),
Size::Medium => self.pr_3(),
Size::Large => self.pr_5(),
_ => self.pr_2(),
}
}
fn input_px(self, size: Size) -> Self {
match size {
Size::XSmall => self.px_1(),
Size::Medium => self.px_3(),
Size::Large => self.px_5(),
_ => self.px_2(),
}
}
fn input_py(self, size: Size) -> Self {
match size {
Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(),
Size::Large => self.py_5(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_6(),
Size::Small => self.h_8(),
Size::Medium => self.h_9(),
Size::Large => self.h_12(),
_ => self.h(px(24.)),
}
.input_font_size(size)
}
fn list_size(self, size: Size) -> Self {
self.list_px(size).list_py(size).input_font_size(size)
}
fn list_px(self, size: Size) -> Self {
match size {
Size::Small => self.px_2(),
_ => self.px_3(),
}
}
fn list_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_2(),
Size::Medium => self.py_1(),
Size::Small => self.py_0p5(),
_ => self.py_1(),
}
}
fn size_with(self, size: Size) -> Self {
match size {
Size::Large => self.size_11(),
Size::Medium => self.size_8(),
Size::Small => self.size_5(),
Size::XSmall => self.size_4(),
Size::Size(size) => self.size(size),
}
}
}
/// A trait for defining element that can be collapsed.
pub trait Collapsible {
fn collapsed(self, collapsed: bool) -> Self;
fn is_collapsed(&self) -> bool;
}

View File

@@ -1,287 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
Window, div, px, white,
};
use theme::{ActiveTheme, Side};
use crate::{Disableable, Sizable, Size};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
pub struct Switch {
id: ElementId,
checked: bool,
disabled: bool,
label: Option<SharedString>,
description: Option<SharedString>,
label_side: Side,
on_click: OnClick,
size: Size,
}
impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
checked: false,
disabled: false,
label: None,
description: None,
on_click: None,
label_side: Side::Left,
size: Size::Medium,
}
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(handler));
self
}
pub fn label_side(mut self, label_side: Side) -> Self {
self.label_side = label_side;
self
}
}
impl Sizable for Switch {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Disableable for Switch {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl IntoElement for Switch {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
#[derive(Default)]
pub struct SwitchState {
prev_checked: Rc<RefCell<Option<bool>>>,
}
impl Element for Switch {
type PrepaintState = ();
type RequestLayoutState = AnyElement;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
window.with_element_state::<SwitchState, _>(global_id.unwrap(), move |state, window| {
let state = state.unwrap_or_default();
let theme = cx.theme();
let checked = self.checked;
let on_click = self.on_click.clone();
let (bg, toggle_bg) = match self.checked {
true => (theme.element_background, white()),
false => (theme.elevated_surface_background, white()),
};
let (bg, toggle_bg) = match self.disabled {
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
false => (bg, toggle_bg),
};
let (bg_width, bg_height) = match self.size {
Size::XSmall | Size::Small => (px(28.), px(16.)),
_ => (px(36.), px(20.)),
};
let bar_width = match self.size {
Size::XSmall | Size::Small => px(12.),
_ => px(16.),
};
let inset = px(2.);
let mut element = div()
.child(
div()
.id(self.id.clone())
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.gap_4()
.when_some(self.label.clone(), |this, label| {
// Label
this.child(
div().text_sm().text_color(cx.theme().text).child(label),
)
})
.child(
// Switch Bar
div()
.id(self.id.clone())
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(theme.border_transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.child(
// Switch Toggle
div()
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
.bg(toggle_bg)
.size(bar_width)
.map(|this| {
let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.is_some_and(|prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(async move |cx| {
cx.background_executor()
.timer(dur)
.await;
*prev_checked.borrow_mut() =
Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as u64,
),
Animation::new(dur),
move |this, delta| {
let max_x = bg_width
- bar_width
- inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x =
bg_width - bar_width - inset * 2;
let x =
if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
}),
),
),
)
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.pr_3()
.text_xs()
.text_color(cx.theme().text_muted)
.child(description),
)
})
.when_some(
on_click
.as_ref()
.map(|c| c.clone())
.filter(|_| !self.disabled),
|this, on_click| {
let prev_checked = state.prev_checked.clone();
this.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
*prev_checked.borrow_mut() = Some(checked);
on_click(&!checked, window, cx);
})
},
),
)
.into_any_element();
((element.request_layout(window, cx), element), state)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
element.prepaint(window, cx);
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
element.paint(window, cx)
}
}

View File

@@ -1,702 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, Edges, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, px, relative,
};
use theme::ActiveTheme;
use crate::{Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
pub mod tab_bar;
/// Tab variants.
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
pub enum TabVariant {
#[default]
Tab,
Outline,
Pill,
Segmented,
Underline,
}
impl TabVariant {
fn height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Underline => px(26.),
_ => px(20.),
},
Size::Small => match self {
TabVariant::Underline => px(30.),
_ => px(24.),
},
Size::Large => match self {
TabVariant::Underline => px(44.),
_ => px(36.),
},
_ => match self {
TabVariant::Underline => px(36.),
_ => px(32.),
},
}
}
fn inner_height(&self, size: Size) -> Pixels {
match size {
Size::XSmall => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(18.),
TabVariant::Segmented => px(16.),
TabVariant::Underline => px(20.),
},
Size::Small => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(22.),
TabVariant::Segmented => px(18.),
TabVariant::Underline => px(22.),
},
Size::Large => match self {
TabVariant::Tab | TabVariant::Outline | TabVariant::Pill => px(36.),
TabVariant::Segmented => px(28.),
TabVariant::Underline => px(32.),
},
_ => match self {
TabVariant::Tab => px(30.),
TabVariant::Outline | TabVariant::Pill => px(26.),
TabVariant::Segmented => px(24.),
TabVariant::Underline => px(26.),
},
}
}
/// Default px(12) to match panel px_3, See [`crate::dock::TabPanel`]
fn inner_paddings(&self, size: Size) -> Edges<Pixels> {
let mut padding_x = match size {
Size::XSmall => px(8.),
Size::Small => px(10.),
Size::Large => px(16.),
_ => px(12.),
};
if matches!(self, TabVariant::Underline) {
padding_x = px(0.);
}
Edges {
left: padding_x,
right: padding_x,
..Default::default()
}
}
fn inner_margins(&self, size: Size) -> Edges<Pixels> {
match size {
Size::XSmall => match self {
TabVariant::Underline => Edges {
top: px(1.),
bottom: px(2.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Small => match self {
TabVariant::Underline => Edges {
top: px(2.),
bottom: px(3.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
Size::Large => match self {
TabVariant::Underline => Edges {
top: px(5.),
bottom: px(6.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
_ => match self {
TabVariant::Underline => Edges {
top: px(3.),
bottom: px(4.),
..Default::default()
},
_ => Edges::all(px(0.)),
},
}
}
fn normal(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().text,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn hovered(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_hover,
borders: Edges::all(px(1.)),
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().secondary_foreground,
bg: cx.theme().secondary_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_foreground,
bg: gpui::transparent_black(),
inner_bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: gpui::transparent_black(),
..Default::default()
},
}
}
fn selected(&self, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: cx.theme().tab_active_background,
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
border_color: cx.theme().border,
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_accent,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: cx.theme().element_active,
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: cx.theme().element_foreground,
bg: cx.theme().element_background,
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
inner_bg: cx.theme().background,
shadow: true,
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().tab_active_foreground,
bg: gpui::transparent_black(),
borders: Edges {
bottom: px(2.),
..Default::default()
},
border_color: cx.theme().element_active,
..Default::default()
},
}
}
fn disabled(&self, selected: bool, cx: &App) -> TabStyle {
match self {
TabVariant::Tab => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
left: px(1.),
right: px(1.),
..Default::default()
},
..Default::default()
},
TabVariant::Outline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
borders: Edges::all(px(1.)),
border_color: if selected {
cx.theme().element_active
} else {
cx.theme().border
},
..Default::default()
},
TabVariant::Pill => TabStyle {
fg: if selected {
cx.theme().element_foreground.opacity(0.5)
} else {
cx.theme().text_muted
},
bg: if selected {
cx.theme().element_background.opacity(0.5)
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Segmented => TabStyle {
fg: cx.theme().text_muted,
bg: cx.theme().tab_background,
inner_bg: if selected {
cx.theme().background
} else {
gpui::transparent_black()
},
..Default::default()
},
TabVariant::Underline => TabStyle {
fg: cx.theme().text_muted,
bg: gpui::transparent_black(),
border_color: if selected {
cx.theme().border
} else {
gpui::transparent_black()
},
borders: Edges {
bottom: px(2.),
..Default::default()
},
..Default::default()
},
}
}
pub(super) fn tab_bar_radius(&self, size: Size, cx: &App) -> Pixels {
if *self != TabVariant::Segmented {
return px(0.);
}
match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
}
}
fn radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Outline | TabVariant::Pill => px(99.),
TabVariant::Segmented => match size {
Size::XSmall | Size::Small => cx.theme().radius,
Size::Large => cx.theme().radius_lg,
_ => cx.theme().radius_lg,
},
_ => px(0.),
}
}
fn inner_radius(&self, size: Size, cx: &App) -> Pixels {
match self {
TabVariant::Segmented => match size {
Size::Large => self.tab_bar_radius(size, cx) - px(3.),
_ => self.tab_bar_radius(size, cx) - px(2.),
},
_ => px(0.),
}
}
}
#[allow(dead_code)]
struct TabStyle {
borders: Edges<Pixels>,
border_color: Hsla,
bg: Hsla,
fg: Hsla,
shadow: bool,
inner_bg: Hsla,
}
impl Default for TabStyle {
fn default() -> Self {
TabStyle {
borders: Edges::all(px(0.)),
border_color: gpui::transparent_white(),
bg: gpui::transparent_white(),
fg: gpui::transparent_white(),
shadow: false,
inner_bg: gpui::transparent_white(),
}
}
}
#[allow(clippy::type_complexity)]
/// A Tab element for the [`super::TabBar`].
#[derive(IntoElement)]
pub struct Tab {
ix: usize,
base: Div,
pub(super) label: Option<SharedString>,
icon: Option<Icon>,
prefix: Option<AnyElement>,
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
children: Vec<AnyElement>,
variant: TabVariant,
size: Size,
pub(super) disabled: bool,
pub(super) selected: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl From<&'static str> for Tab {
fn from(label: &'static str) -> Self {
Self::new().label(label)
}
}
impl From<String> for Tab {
fn from(label: String) -> Self {
Self::new().label(label)
}
}
impl From<SharedString> for Tab {
fn from(label: SharedString) -> Self {
Self::new().label(label)
}
}
impl From<Icon> for Tab {
fn from(icon: Icon) -> Self {
Self::default().icon(icon)
}
}
impl From<IconName> for Tab {
fn from(icon_name: IconName) -> Self {
Self::default().icon(Icon::new(icon_name))
}
}
impl Default for Tab {
fn default() -> Self {
Self {
ix: 0,
base: div(),
label: None,
icon: None,
tab_bar_prefix: None,
children: Vec::new(),
disabled: false,
selected: false,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
on_click: None,
}
}
}
impl Tab {
/// Create a new tab with a label.
pub fn new() -> Self {
Self::default()
}
/// Set label for the tab.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set icon for the tab.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set Tab Variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Use Pill variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Use outline variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Use Segmented variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Use Underline variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set disabled state to the tab, default false.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the click handler for the tab.
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// Set index to the tab.
pub(crate) fn ix(mut self, ix: usize) -> Self {
self.ix = ix;
self
}
/// Set if the tab bar has a prefix.
pub(crate) fn tab_bar_prefix(mut self, tab_bar_prefix: bool) -> Self {
self.tab_bar_prefix = Some(tab_bar_prefix);
self
}
}
impl ParentElement for Tab {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Selectable for Tab {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Styled for Tab {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl Sizable for Tab {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let mut tab_style = if self.selected {
self.variant.selected(cx)
} else {
self.variant.normal(cx)
};
let mut hover_style = self.variant.hovered(self.selected, cx);
if self.disabled {
tab_style = self.variant.disabled(self.selected, cx);
hover_style = self.variant.disabled(self.selected, cx);
}
let tab_bar_prefix = self.tab_bar_prefix.unwrap_or_default();
if !tab_bar_prefix && self.ix == 0 && self.variant == TabVariant::Tab {
tab_style.borders.left = px(0.);
hover_style.borders.left = px(0.);
}
let radius = self.variant.radius(self.size, cx);
let inner_radius = self.variant.inner_radius(self.size, cx);
let inner_paddings = self.variant.inner_paddings(self.size);
let inner_margins = self.variant.inner_margins(self.size);
let inner_height = self.variant.inner_height(self.size);
let height = self.variant.height(self.size);
self.base
.id(self.ix)
.flex()
.flex_wrap()
.gap_1()
.items_center()
.flex_shrink_0()
.h(height)
.overflow_hidden()
.text_color(tab_style.fg)
.map(|this| match self.size {
Size::XSmall => this.text_xs(),
Size::Large => this.text_base(),
_ => this.text_sm(),
})
.bg(tab_style.bg)
.border_l(tab_style.borders.left)
.border_r(tab_style.borders.right)
.border_t(tab_style.borders.top)
.border_b(tab_style.borders.bottom)
.border_color(tab_style.border_color)
.rounded(radius)
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| {
this.text_color(hover_style.fg)
.bg(hover_style.bg)
.border_l(hover_style.borders.left)
.border_r(hover_style.borders.right)
.border_t(hover_style.borders.top)
.border_b(hover_style.borders.bottom)
.border_color(hover_style.border_color)
.rounded(radius)
})
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.flex_1()
.h(inner_height)
.line_height(relative(1.))
.whitespace_nowrap()
.items_center()
.justify_center()
.overflow_hidden()
.margins(inner_margins)
.flex_shrink_0()
.map(|this| match self.icon {
Some(icon) => {
this.w(inner_height * 1.25)
.child(icon.map(|this| match self.size {
Size::XSmall => this.size_2p5(),
Size::Small => this.size_3p5(),
Size::Large => this.size_4(),
_ => this.size_4(),
}))
}
None => this
.paddings(inner_paddings)
.map(|this| match self.label {
Some(label) => this.child(label),
None => this,
})
.children(self.children),
})
.bg(tab_style.inner_bg)
.rounded(inner_radius)
.when(tab_style.shadow, |this| this.shadow_xs())
.hover(|this| this.bg(hover_style.inner_bg).rounded(inner_radius)),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().child(suffix))
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
// Stop propagation behavior, for works on TitleBar.
// https://github.com/longbridge/gpui-component/issues/1836
cx.stop_propagation();
})
.when(!self.disabled, |this| {
this.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
}
}

View File

@@ -1,290 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Div, Edges, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::{Tab, TabVariant};
use crate::button::{Button, ButtonVariants as _};
use crate::menu::{DropdownMenu as _, PopupMenuItem};
use crate::{IconName, Selectable, Sizable, Size, StyledExt, h_flex};
#[allow(clippy::type_complexity)]
/// A TabBar element that contains multiple [`Tab`] items.
#[derive(IntoElement)]
pub struct TabBar {
base: Stateful<Div>,
style: StyleRefinement,
scroll_handle: Option<ScrollHandle>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
children: SmallVec<[Tab; 2]>,
last_empty_space: AnyElement,
selected_index: Option<usize>,
variant: TabVariant,
size: Size,
menu: bool,
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
impl TabBar {
/// Create a new TabBar.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().id(id).px(px(-1.)),
style: StyleRefinement::default(),
children: SmallVec::new(),
scroll_handle: None,
prefix: None,
suffix: None,
variant: TabVariant::default(),
size: Size::default(),
last_empty_space: div().w_3().into_any_element(),
selected_index: None,
on_click: None,
menu: false,
}
}
/// Set the Tab variant, all children will inherit the variant.
pub fn with_variant(mut self, variant: TabVariant) -> Self {
self.variant = variant;
self
}
/// Set the Tab variant to Pill, all children will inherit the variant.
pub fn pill(mut self) -> Self {
self.variant = TabVariant::Pill;
self
}
/// Set the Tab variant to Outline, all children will inherit the variant.
pub fn outline(mut self) -> Self {
self.variant = TabVariant::Outline;
self
}
/// Set the Tab variant to Segmented, all children will inherit the variant.
pub fn segmented(mut self) -> Self {
self.variant = TabVariant::Segmented;
self
}
/// Set the Tab variant to Underline, all children will inherit the variant.
pub fn underline(mut self) -> Self {
self.variant = TabVariant::Underline;
self
}
/// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu;
self
}
/// Track the scroll of the TabBar.
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone());
self
}
/// Set the prefix element of the TabBar
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the suffix element of the TabBar
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Add children of the TabBar, all children will inherit the variant.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
/// Add child of the TabBar, tab will inherit the variant.
pub fn child(mut self, child: impl Into<Tab>) -> Self {
self.children.push(child.into());
self
}
/// Set the selected index of the TabBar.
pub fn selected_index(mut self, index: usize) -> Self {
self.selected_index = Some(index);
self
}
/// Set the last empty space element of the TabBar.
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element();
self
}
/// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
///
/// When this is set, the children's on_click will be ignored.
pub fn on_click<F>(mut self, on_click: F) -> Self
where
F: Fn(&usize, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(on_click));
self
}
}
impl Styled for TabBar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Sizable for TabBar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let default_gap = match self.size {
Size::Small | Size::XSmall => px(8.),
Size::Large => px(16.),
_ => px(12.),
};
let (bg, paddings, gap) = match self.variant {
TabVariant::Tab => {
let padding = Edges::all(px(0.));
(cx.theme().tab_background, padding, px(0.))
}
TabVariant::Outline => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, default_gap)
}
TabVariant::Pill => {
let padding = Edges::all(px(0.));
(gpui::transparent_black(), padding, px(4.))
}
TabVariant::Segmented => {
let padding_x = match self.size {
Size::XSmall => px(2.),
Size::Small => px(3.),
_ => px(4.),
};
let padding = Edges {
left: padding_x,
right: padding_x,
..Default::default()
};
(cx.theme().tab_background, padding, px(2.))
}
TabVariant::Underline => {
// This gap is same as the tab inner_paddings
let gap = match self.size {
Size::XSmall => px(10.),
Size::Small => px(12.),
Size::Large => px(20.),
_ => px(16.),
};
(gpui::transparent_black(), Edges::all(px(0.)), gap)
}
};
let mut item_labels = Vec::new();
let selected_index = self.selected_index;
let on_click = self.on_click.clone();
self.base
.group("tab-bar")
.relative()
.flex()
.items_center()
.bg(bg)
.text_color(cx.theme().tab_foreground)
.when(
self.variant == TabVariant::Underline || self.variant == TabVariant::Tab,
|this| {
this.child(
div()
.id("border-b")
.absolute()
.left_0()
.bottom_0()
.size_full()
.border_b_1()
.border_color(cx.theme().border),
)
},
)
.rounded(self.variant.tab_bar_radius(self.size, cx))
.paddings(paddings)
.refine_style(&self.style)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.id("tabs")
.flex_1()
.overflow_x_scroll()
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.gap(gap)
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.with_variant(self.variant)
.with_size(self.size)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})
.when_some(self.on_click.clone(), move |this, on_click| {
this.on_click(move |_, window, cx| on_click(&ix, window, cx))
})
}))
.when(self.suffix.is_some() || self.menu, |this| {
this.child(self.last_empty_space)
}),
)
.when(self.menu, |this| {
this.child(
Button::new("more")
.xsmall()
.ghost()
.icon(IconName::ChevronDown)
.dropdown_menu(move |mut this, _, _| {
this = this.scrollable(true);
for (ix, (label, disabled)) in item_labels.iter().enumerate() {
this = this.item(
PopupMenuItem::new(label.clone().unwrap_or_default())
.checked(selected_index == Some(ix))
.disabled(*disabled)
.when_some(on_click.clone(), |this, on_click| {
this.on_click(move |_, window, cx| {
on_click(&ix, window, cx)
})
}),
)
}
this
})
.anchor(Anchor::TopRight),
)
})
.when_some(self.suffix, |this, suffix| this.child(suffix))
}
}

View File

@@ -1,36 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
};
use theme::ActiveTheme;
pub struct Tooltip {
text: SharedString,
}
impl Tooltip {
pub fn new(text: impl Into<SharedString>, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { text: text.into() })
}
}
impl Render for Tooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(
div()
.font_family(".SystemUIFont")
.m_3()
.p_1p5()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_background)
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().text)
.line_height(relative(1.25))
.child(self.text.clone()),
)
}
}

View File

@@ -1,133 +0,0 @@
use std::rc::Rc;
use gpui::{App, ElementId, Entity, Window};
use crate::Root;
use crate::input::InputState;
use crate::modal::Modal;
use crate::notification::Notification;
/// Extension trait for [`Window`] to add modal, notification .. functionality.
pub trait WindowExtension: Sized {
/// Opens a Modal.
fn open_modal<F>(&mut self, cx: &mut App, builder: F)
where
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static;
/// Return true, if there is an active Modal.
fn has_active_modal(&mut self, cx: &mut App) -> bool;
/// Closes the last active Modal.
fn close_modal(&mut self, cx: &mut App);
/// Closes all active Modals.
fn close_all_modals(&mut self, cx: &mut App);
/// Returns number of notifications.
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>>;
/// Pushes a notification to the notification list.
fn push_notification<T>(&mut self, note: T, cx: &mut App)
where
T: Into<Notification>;
/// Clear the unique notification.
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App);
/// Clear the unique notification with the given id.
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
);
/// 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<Entity<InputState>>;
/// Returns true if there is a focused Input entity.
fn has_focused_input(&mut self, cx: &mut App) -> bool;
}
impl WindowExtension for Window {
#[inline]
fn open_modal<F>(&mut self, cx: &mut App, builder: F)
where
F: Fn(Modal, &mut Window, &mut App) -> Modal + 'static,
{
Root::update(self, cx, move |root, window, cx| {
root.open_modal(builder, window, cx);
})
}
#[inline]
fn has_active_modal(&mut self, cx: &mut App) -> bool {
Root::read(self, cx).has_active_modals()
}
#[inline]
fn close_modal(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.close_modal(window, cx);
})
}
#[inline]
fn close_all_modals(&mut self, cx: &mut App) {
Root::update(self, cx, |root, window, cx| {
root.close_all_modals(window, cx);
})
}
#[inline]
fn push_notification<T>(&mut self, note: T, cx: &mut App)
where
T: Into<Notification>,
{
let note = note.into();
Root::update(self, cx, move |root, window, cx| {
root.push_notification(note, window, cx);
})
}
#[inline]
fn clear_notification<T: Sized + 'static>(&mut self, cx: &mut App) {
Root::update(self, cx, |root, window, cx| {
root.clear_notification::<T>(window, cx);
})
}
#[inline]
fn clear_notification_by_id<T: Sized + 'static>(
&mut self,
key: impl Into<ElementId>,
cx: &mut App,
) {
let key: ElementId = key.into();
Root::update(self, cx, |root, window, cx| {
root.clear_notification_by_id::<T>(key, window, cx);
})
}
#[inline]
fn clear_notifications(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.clear_notifications(window, cx);
})
}
fn notifications(&mut self, cx: &mut App) -> Rc<Vec<Entity<Notification>>> {
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<Entity<InputState>> {
Root::read(self, cx).focused_input.clone()
}
}

View File

@@ -28,9 +28,7 @@ icons = [
[dependencies]
assets = { path = "../crates/assets" }
ui = { path = "../crates/ui" }
title_bar = { path = "../crates/title_bar" }
theme = { path = "../crates/theme" }
common = { path = "../crates/common" }
state = { path = "../crates/state" }
device = { path = "../crates/device" }
@@ -47,6 +45,7 @@ gpui_linux.workspace = true
gpui_windows.workspace = true
gpui_macos.workspace = true
gpui_tokio.workspace = true
gpui-component.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true

View File

@@ -1,17 +1,17 @@
use anyhow::Error;
use common::CoopIcon;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, div, px,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, StateEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
@@ -45,7 +45,7 @@ impl AccountSelector {
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
StateEvent::SignerSet => {
window.close_all_modals(cx);
window.close_all_dialogs(cx);
window.refresh();
}
StateEvent::Error(e) => {
@@ -124,10 +124,10 @@ impl AccountSelector {
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.close_button(true)
.pb_2()
.child(import.clone())
});
@@ -136,10 +136,10 @@ impl AccountSelector {
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.close_button(true)
.pb_2()
.child(connect.clone())
});
@@ -162,7 +162,7 @@ impl Render for AccountSelector {
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
@@ -182,15 +182,15 @@ impl Render for AccountSelector {
.justify_between()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.child(
h_flex()
.gap_2()
.child(Avatar::new(profile.avatar()).small())
.child(Avatar::new().src(profile.avatar()).small())
.child(div().text_sm().child(profile.name())),
)
.when(logging_in, |this| this.child(Indicator::new().small()))
.when(logging_in, |this| this.child(Spinner::new().small()))
.when(!logging_in, |this| {
this.child(
h_flex()
@@ -199,7 +199,7 @@ impl Render for AccountSelector {
.group_hover("", |this| this.visible())
.child(
Button::new(format!("del-{ix}"))
.icon(IconName::Close)
.icon(CoopIcon::Close)
.ghost()
.small()
.disabled(logging_in)
@@ -224,7 +224,7 @@ impl Render for AccountSelector {
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(div().w_full().h_px().bg(cx.theme().border))
.child(
h_flex()
.gap_1()
@@ -232,7 +232,7 @@ impl Render for AccountSelector {
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.icon(Icon::new(CoopIcon::Usb))
.label("Import")
.ghost()
.small()
@@ -243,7 +243,7 @@ impl Render for AccountSelector {
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.icon(Icon::new(CoopIcon::Scan))
.label("Scan QR to connect")
.ghost()
.small()

View File

@@ -7,13 +7,12 @@ use gpui::{
AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window, div, img, px,
};
use gpui_component::{ActiveTheme, v_flex};
use nostr_connect::prelude::*;
use state::{
CLIENT_NAME, CoopAuthUrlHandler, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT, NostrRegistry,
StateEvent,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
@@ -101,14 +100,14 @@ impl Render for ConnectSigner {
div()
.text_xs()
.text_center()
.text_color(cx.theme().text_danger)
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(MSG)),
)
}

Some files were not shown because too many files have changed in this diff Show More